diff options
author | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
---|---|---|
committer | Marc Pervaz Boocha <mboocha@sudomsg.com> | 2025-08-02 20:55:11 +0530 |
commit | ce6cf13c2d67c3368251d1eea5593198f5021330 (patch) | |
tree | d4c2347fd45fce395bf22a30e423697494d4284f | |
download | kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar.gz kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar.bz2 kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar.lz kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar.xz kit-ce6cf13c2d67c3368251d1eea5593198f5021330.tar.zst kit-ce6cf13c2d67c3368251d1eea5593198f5021330.zip |
Initial Commitv0.1.0
-rw-r--r-- | LICENSE | 22 | ||||
-rw-r--r-- | README.md | 14 | ||||
-rw-r--r-- | generic/generic.go | 106 | ||||
-rw-r--r-- | generic/generic_test.go | 149 | ||||
-rw-r--r-- | go.mod | 5 | ||||
-rw-r--r-- | go.sum | 2 | ||||
-rw-r--r-- | http/server.go | 146 | ||||
-rw-r--r-- | http/server_test.go | 159 | ||||
-rw-r--r-- | logging/http/http.go | 134 | ||||
-rw-r--r-- | logging/http/http_test.go | 113 | ||||
-rw-r--r-- | logging/log.go | 136 | ||||
-rw-r--r-- | logging/log_test.go | 141 | ||||
-rw-r--r-- | logging/test/handler.go | 112 | ||||
-rw-r--r-- | runner/run.go | 58 |
14 files changed, 1297 insertions, 0 deletions
@@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Marc Pervaz Boocha + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..1f0290b --- /dev/null +++ b/README.md @@ -0,0 +1,14 @@ +# Kit + +A collection of idiomatic, reusable Go utility packages and helpers for building CLI tools, HTTP servers, and concurrent applications — designed for personal use and easy sharing across projects. + +[Documentation](https://pkg.go.dev/go.sudomsg.com/cache) + +## Installation + +To use Kit in your Go project, you can install it using `go get`: + +```sh +go get go.sudomsg.com/Kit +``` + diff --git a/generic/generic.go b/generic/generic.go new file mode 100644 index 0000000..8ac3c51 --- /dev/null +++ b/generic/generic.go @@ -0,0 +1,106 @@ +// Package generic provides common generic utility functions and types. +// +// It includes zero value helpers, pointer helpers, iteration helpers for map-like sequences, +// and a simple generic Set implementation. +package generic + +import ( + "iter" + "maps" +) + +// Package generic provides common generic utility functions and types. +// +// It includes zero value helpers, pointer helpers, iteration helpers for map-like sequences, +// and a simple generic Set implementation. +func Zero[T any]() T { + var v T + return v +} + +// IsZero reports whether the given value is the zero value of its type. +// +// T must be comparable (support == operator). +// +// Example: +// +// generic.IsZero(0) // true +// generic.IsZero("a") // false +func IsZero[T comparable](v T) bool { + return v == Zero[T]() +} + +// Ptr returns a pointer to the given value. +// +// This is a concise helper to get the address of a value inline. +// +// Example: +// +// p := generic.Ptr(42) // *int with value 42 +func Ptr[T any](v T) *T { + return &v +} + +// Keys returns an iterator (Seq) over the keys of the input map-like sequence. +// +// Utilizes iter.Seq2[K,V] as input and yields keys K. +// +// Usage example (assuming iter.Seq is a func type yielding values): +// +// forKeys := generic.Keys(yourMapSeq) +// forKeys(func(k K) bool { +// fmt.Println(k) +// return true +// }) +func Keys[K comparable, V any](m iter.Seq2[K, V]) iter.Seq[K] { + return func(yield func(K) bool) { + for k := range m { + if !yield(k) { + return + } + } + } +} + +// Value returns an iterator over the values of the input map-like sequence. +// +// Usage is analogous to Keys() but yields values of type V. +func Value[K comparable, V any](m iter.Seq2[K, V]) iter.Seq[V] { + return func(yield func(V) bool) { + for _, v := range m { + if !yield(v) { + return + } + } + } +} + +// Set represents a generic set of comparable items. +// +// It is a thin wrapper around a map[T]struct{} for set semantics. +type Set[T comparable] map[T]struct{} + +func NewSet[T comparable]() Set[T] { + return make(Set[T]) +} + +// All returns an iterator (Seq) over all elements in the Set. +func (s Set[T]) All() iter.Seq[T] { + return maps.Keys(s) +} + +// Add inserts the value v into the Set. +func (s Set[T]) Add(v T) { + s[v] = struct{}{} +} + +// Del removes the value v from the Set. +func (s Set[T]) Del(v T) { + delete(s, v) +} + +// Has reports whether the value v is present in the Set. +func (s Set[T]) Has(v T) bool { + _, ok := s[v] + return ok +} diff --git a/generic/generic_test.go b/generic/generic_test.go new file mode 100644 index 0000000..953f2ac --- /dev/null +++ b/generic/generic_test.go @@ -0,0 +1,149 @@ +package generic + +import ( + "maps" + "slices" + "testing" +) + +type X struct{} + +func TestZero(t *testing.T) { + if Zero[int]() != 0 { + t.Errorf("Zero[int]() != 0") + } + if Zero[string]() != "" { + t.Errorf(`Zero[string]() != ""`) + } + if Zero[bool]() != false { + t.Errorf("Zero[bool]() != false") + } + if Zero[*X]() != nil { + t.Errorf("Zero[*X]() != nil") + } +} + +func TestIsZero(t *testing.T) { + tests := []struct { + name string + val any + want bool + }{ + { + name: "int zero", + val: 0, + want: true, + }, + { + "int non-zero", + 1, + false, + }, + { + "string zero", + "", + true, + }, + { + "string non-zero", + "x", + false, + }, + { + "nil pointer", + (*X)(nil), + true, + }, + { + "non-nil pointer", + Ptr(X{}), + false, + }, + } + + for _, tt := range tests { + switch v := tt.val.(type) { + case int: + got := IsZero(v) + if got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + case string: + got := IsZero(v) + if got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + case *X: + got := IsZero(v) + if got != tt.want { + t.Errorf("%s: got %v, want %v", tt.name, got, tt.want) + } + } + } +} + +func TestPtr(t *testing.T) { + v := 42 + p := Ptr(v) + if p == nil || *p != v { + t.Errorf("Ptr(42) = %v, want pointer to 42", p) + } +} + +func TestKeys(t *testing.T) { + m := map[string]int{"a": 1, "b": 2, "c": 3} + seq := Keys(maps.All(m)) + keys := slices.Collect(seq) + want := slices.Collect(maps.Keys(m)) + + slices.Sort(keys) + slices.Sort(want) + if !slices.Equal(keys, want) { + t.Errorf("Keys() = %v, want %v", keys, want) + } +} + +func TestValue(t *testing.T) { + m := map[string]int{"a": 1, "b": 2, "c": 3} + seq := Value(maps.All(m)) + values := slices.Collect(seq) + want := slices.Collect(maps.Values(m)) + + // unordered compare + slices.Sort(values) + slices.Sort(want) + if !slices.Equal(values, want) { + t.Errorf("Values() = %v, want %v", values, want) + } +} + +func TestSet_Add_Has_Del(t *testing.T) { + s := NewSet[int]() + if s.Has(10) { + t.Errorf("Set should not contain 10 initially") + } + s.Add(10) + if !s.Has(10) { + t.Errorf("Set should contain 10 after Add") + } + s.Del(10) + if s.Has(10) { + t.Errorf("Set should not contain 10 after Del") + } +} + +func TestSet_All(t *testing.T) { + s := NewSet[string]() + s.Add("x") + s.Add("y") + s.Add("z") + + got := slices.Collect(s.All()) + want := []string{"x", "y", "z"} + + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + t.Errorf("Set.All() = %v, want %v", got, want) + } +} @@ -0,0 +1,5 @@ +module go.sudomsg.com/kit + +go 1.24.1 + +require golang.org/x/sync v0.16.0 @@ -0,0 +1,2 @@ +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= diff --git a/http/server.go b/http/server.go new file mode 100644 index 0000000..e13bc67 --- /dev/null +++ b/http/server.go @@ -0,0 +1,146 @@ +// Package http provides utilities for managing HTTP servers and listeners. +// +// It supports opening multiple network listeners from configuration, +// running multiple HTTP servers concurrently with graceful shutdown, +// and integrates structured logging with context. +// +// This package is designed to be used with context cancellation for concurrency control. + +package http + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "sync" + "time" + + "go.sudomsg.com/kit/logging" + "golang.org/x/sync/errgroup" +) + +// Listeners is a slice of net.Listener interfaces representing multiple network listeners. +type Listeners []net.Listener + +// CloseAll closes all listeners in the slice. +// It aggregates all errors returned by individual Close calls using errors.Join. +// +// If no errors occur, it returns nil. +func (ls Listeners) CloseAll() error { + var errs []error + for _, l := range ls { + if err := l.Close(); err != nil { + errs = append(errs, err) + } + } + if len(errs) > 0 { + return errors.Join(errs...) + } + return nil +} + +// ServerConfig defines a single network listener configuration. +// +// Network is the network type, e.g., "tcp". +// Address is the socket address, e.g., ":8080". +type ServerConfig struct { + Network string `toml:"network"` + Address string `toml:"network"` +} + +// OpenConfigListeners opens network listeners as specified by the provided ServerConfig slice. +// +// It attempts to open all listeners concurrently and returns them if all succeed. +// +// If any listener fails to open, it closes all previously opened listeners and returns an error. +// +// The context controls cancellation of the opening process. +func OpenConfigListners(ctx context.Context, config []ServerConfig) (Listeners, error) { + lns := make(Listeners, 0, len(config)) + var mu sync.Mutex + g, ctx := errgroup.WithContext(ctx) + var lc net.ListenConfig + for _, cfg := range config { + g.Go(func() error { + network := cfg.Network + if network == "" { + network = "tcp" + } + ln, err := lc.Listen(ctx, network, cfg.Address) + if err != nil { + return fmt.Errorf("failed to listen on %s %s: %w", network, cfg.Address, err) + } + + mu.Lock() + lns = append(lns, ln) + mu.Unlock() + return nil + }) + } + if err := g.Wait(); err != nil { + _ = lns.CloseAll() + return nil, err + } + return lns, nil +} + +func OpenListeners(ctx context.Context, config []ServerConfig) (Listeners, error) { + return OpenConfigListners(ctx, config) +} + +// RunHTTPServers runs HTTP servers concurrently on all provided listeners. +// +// The provided handler is used for all servers. +// Servers respond to context cancellation by performing a graceful shutdown with a timeout of 10 seconds. +// +// Logging is performed using the slog.Logger extracted from context. +// Each server logs startup, shutdown, and errors. +// +// This function blocks until all servers have stopped or an error occurs. +func RunHTTPServers(ctx context.Context, lns Listeners, handler http.Handler) error { + g, ctx := errgroup.WithContext(ctx) + + for _, ln := range lns { + g.Go(func() error { + logger, ctx := logging.With(ctx, "address", ln.Addr) + + srv := &http.Server{ + Addr: ln.Addr().String(), + Handler: handler, + BaseContext: func(l net.Listener) context.Context { + return ctx + }, + ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelError), + } + + logger.Log(ctx, slog.LevelInfo, "HTTP server serving") + + if err := httpServeContext(ctx, srv, ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + return fmt.Errorf("HTTP server Serve Error: %w", err) + } + return nil + }) + + } + return g.Wait() +} + +func httpServeContext(ctx context.Context, srv *http.Server, ln net.Listener) error { + logger := logging.FromContext(ctx) + go func() { + <-ctx.Done() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + err := srv.Shutdown(ctx) + if err != nil { + logger.Log(ctx, slog.LevelWarn, "HTTP server Shutdown Error", slog.Any("error", err)) + } else { + logger.Log(ctx, slog.LevelInfo, "HTTP Server Shutdown Complete") + } + }() + return srv.Serve(ln) +} diff --git a/http/server_test.go b/http/server_test.go new file mode 100644 index 0000000..7138c73 --- /dev/null +++ b/http/server_test.go @@ -0,0 +1,159 @@ +package http_test + +import ( + "context" + "io" + "net" + "net/http" + "strings" + "testing" + "time" + + httpServer "go.sudomsg.com/kit/http" +) + +// helper to find free TCP ports +func newListener(tb testing.TB) net.Listener { + tb.Helper() + + ln, err := net.Listen("tcp", "localhost:0") + if err != nil { + tb.Fatalf("failed to open listener: %v", err) + } + return ln +} + +func TestOpenConfigListeners(t *testing.T) { + t.Parallel() + t.Run("successful config", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + cfg := []httpServer.ServerConfig{ + {Network: "tcp", Address: "localhost:0"}, + {Network: "tcp", Address: "localhost:0"}, + } + + lns, err := httpServer.OpenConfigListners(ctx, cfg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + defer lns.CloseAll() + + if got := len(lns); got != len(cfg) { + t.Errorf("expected %d listeners, got %d", len(cfg), got) + } + }) + + t.Run("port conflict triggers cleanup", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + + conflict := newListener(t) + defer conflict.Close() + + cfg := []httpServer.ServerConfig{ + {Network: "tcp", Address: "localhost:0"}, + {Network: "tcp", Address: conflict.Addr().String()}, // will fail + } + + lns, err := httpServer.OpenConfigListners(ctx, cfg) + if err == nil { + defer lns.CloseAll() + t.Fatal("expected error due to conflict, got nil") + } + }) +} + +func TestCloseAll(t *testing.T) { + t.Run("closes all listeners", func(t *testing.T) { + t.Parallel() + ln1 := newListener(t) + ln2 := newListener(t) + + ls := httpServer.Listeners{ln1, ln2} + err := ls.CloseAll() + if err != nil { + t.Errorf("unexpected error from CloseAll: %v", err) + } + + for _, ln := range ls { + if _, err := ln.Accept(); err == nil { + t.Error("expected listener to be closed, but Accept succeeded") + } + } + }) +} + +func TestRunHTTPServers(t *testing.T) { + t.Parallel() + t.Run("basic serve and shutdown", func(t *testing.T) { + t.Parallel() + ctx := t.Context() + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + io.WriteString(w, "hello") + }) + + ln := newListener(t) + addr := ln.Addr().String() + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + go func() { + // Wait for server to be ready + time.Sleep(200 * time.Millisecond) + + r, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+addr, nil) + resp, err := http.DefaultClient.Do(r) + if err != nil { + t.Errorf("http.Do error: %v", err) + return + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + if got := strings.TrimSpace(string(body)); got != "hello" { + t.Errorf("unexpected response body: %q", got) + } + + cancel() // shutdown the server + }() + + err := httpServer.RunHTTPServers(ctx, httpServer.Listeners{ln}, handler) + if err != nil { + t.Fatalf("RunHTTPServers failed: %v", err) + } + }) +} + +func TestInvalidConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + config httpServer.ServerConfig + }{ + { + name: "invalid network", + config: httpServer.ServerConfig{Network: "invalid", Address: "localhost:0"}, + }, + { + name: "invalid address", + config: httpServer.ServerConfig{Network: "tcp", Address: "::::"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := t.Context() + + _, err := httpServer.OpenConfigListners(ctx, []httpServer.ServerConfig{tt.config}) + if err == nil { + t.Fatal("OpenConfigListners() expected error, got nil") + } + }) + } +} diff --git a/logging/http/http.go b/logging/http/http.go new file mode 100644 index 0000000..b88ae3a --- /dev/null +++ b/logging/http/http.go @@ -0,0 +1,134 @@ +// Package http provides HTTP middleware and utilities, including request logging using slog. +// +// The LoggingHandler middleware logs HTTP request details, response status, bytes written, +// and handles panics gracefully with stack trace logging. + +package http + +import ( + "bufio" + "log/slog" + "net" + "net/http" + "time" + + "go.sudomsg.com/kit/logging" +) + +type tracingWriter struct { + http.ResponseWriter + StatusCode int + BytesWritten int + Hijacked bool +} + +var ( + _ http.ResponseWriter = &tracingWriter{} + _ http.Flusher = &tracingWriter{} + _ http.Hijacker = &tracingWriter{} + _ interface{ Unwrap() http.ResponseWriter } = &tracingWriter{} +) + +func (tw *tracingWriter) WriteHeader(code int) { + tw.ResponseWriter.WriteHeader(code) + tw.StatusCode = code +} + +func (tw *tracingWriter) Write(b []byte) (int, error) { + if tw.StatusCode == 0 { + tw.WriteHeader(http.StatusOK) + } + n, err := tw.ResponseWriter.Write(b) + tw.BytesWritten += n + return n, err +} + +func (tw *tracingWriter) Flush() { + rc := http.NewResponseController(tw.ResponseWriter) + _ = rc.Flush() +} + +func (tw *tracingWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { + rc := http.NewResponseController(tw.ResponseWriter) + conn, rw, err := rc.Hijack() + if err == nil { + tw.Hijacked = true + } + return conn, rw, err +} + +func (tw *tracingWriter) Unwrap() http.ResponseWriter { + return tw.ResponseWriter +} + +// LoggingHandler is an HTTP middleware that logs requests and responses using slog. +// +// It recovers panics during request processing, logs them with stack traces, +// and returns a 500 Internal Server Error if the connection is not hijacked. +type LoggingHandler struct { + next http.Handler + logger *slog.Logger +} + +var _ http.Handler = &LoggingHandler{} + +// New creates a new LoggingHandler wrapping the next http.Handler, using the provided slog.Logger. +// +// If logger is nil, it attempts to obtain one from the request context. +func New(next http.Handler, logger *slog.Logger) *LoggingHandler { + return &LoggingHandler{ + next: next, + logger: logger, + } +} + +// ServeHTTP implements the http.Handler interface. +// +// It logs the HTTP method, path, client IP, protocol, host, user-agent, referer, +// request latency, response status, and bytes written. +// It also recovers panics, logs them, and returns HTTP 500 if appropriate. +func (h *LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + logger := h.logger + if logger == nil { + logger = logging.FromContext(ctx) + } + + logger = logger.With( + slog.String("method", r.Method), + slog.String("path", r.URL.RequestURI()), + slog.String("ip", r.RemoteAddr), + ) + ctx = logging.WithLogger(ctx, logger) + r = r.WithContext(ctx) + + tw := &tracingWriter{ResponseWriter: w} + + defer func() { + if err := recover(); err != nil { + logging.RecoverAndLog(ctx, "http panic", err) + if !tw.Hijacked && tw.StatusCode == 0 { + http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + } + } + }() + + start := time.Now() + + h.next.ServeHTTP(tw, r) + + if !tw.Hijacked { + latency := time.Since(start) + + logger.LogAttrs(r.Context(), slog.LevelInfo, "HTTP request Handled", + slog.String("proto", r.Proto), + slog.String("host", r.Host), + slog.String("user-agent", r.UserAgent()), + slog.String("referer", r.Referer()), + slog.Duration("latency", latency), + slog.Int("status", tw.StatusCode), + slog.Int("bytes", tw.BytesWritten), + ) + } +} diff --git a/logging/http/http_test.go b/logging/http/http_test.go new file mode 100644 index 0000000..26e5974 --- /dev/null +++ b/logging/http/http_test.go @@ -0,0 +1,113 @@ +package http_test + +import ( + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + httpHandler "go.sudomsg.com/kit/logging/http" + "go.sudomsg.com/kit/logging/test" +) + +func TestNew(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + url string + status int + handler http.HandlerFunc + }{ + { + name: "No Status", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusOK, + handler: func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte{}) + }, + }, + { + name: "NotFound", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusNotFound, + handler: http.NotFound, + }, + { + name: "Query", + method: http.MethodGet, + url: "http://example.com/a?b", + status: http.StatusNotFound, + handler: http.NotFound, + }, + { + name: "Flush", + method: http.MethodGet, + url: "http://example.com/", + status: http.StatusOK, + handler: func(w http.ResponseWriter, r *http.Request) { + rc := http.NewResponseController(w) + w.Write([]byte{}) + rc.Flush() + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log := test.NewMockLogHandler(t) + + logger := slog.New(log) + + handler := httpHandler.New(tt.handler, logger) + + r := httptest.NewRequest(tt.method, tt.url, nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, r) + + records := log.Records() + + if len(records) != 1 { + t.Fatalf("expected 1 log record, got %d", len(records)) + } + + record := records[0] + + attrs := map[string]slog.Value{} + record.Attrs(func(a slog.Attr) bool { + attrs[a.Key] = a.Value + return true + }) + + method := attrs["method"] + if got := method.String(); got != tt.method { + t.Fatalf("expected %v log record, got %v", tt.method, got) + } + status := attrs["status"] + if got := int(status.Int64()); got != tt.status { + t.Fatalf("expected %v log record, got %v", tt.status, got) + } + + url, err := url.Parse(tt.url) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + + host := attrs["host"] + if got := host.String(); got != url.Host { + t.Fatalf("expected %v log record, got %v", url.Host, got) + } + path := attrs["path"] + if got := path.String(); got != url.RequestURI() { + t.Fatalf("expected %v log record, got %v", url.RequestURI(), got) + } + }) + } +} diff --git a/logging/log.go b/logging/log.go new file mode 100644 index 0000000..8dc8017 --- /dev/null +++ b/logging/log.go @@ -0,0 +1,136 @@ +// Package logging provides helpers for context-aware logging using Go's slog package. +// +// It allows attaching loggers to context.Context for structured logging, +// recovering and logging panics with stack traces, +// and provides a simple package-level logger setup function. +package logging + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "runtime" + "strings" +) + +type ctxLoggerKeyType struct{} + +var ctxLoggerKey = ctxLoggerKeyType{} + +// WithLogger returns a new context derived from ctx that carries the provided slog.Logger. +// +// Typical usage: +// +// ctx = logging.WithLogger(ctx, logger) +func WithLogger(ctx context.Context, logger *slog.Logger) context.Context { + return context.WithValue(ctx, ctxLoggerKey, logger) +} + +// FromContext retrieves the slog.Logger stored in ctx if any. +// If no logger is attached to the context, returns slog.Default(). +func FromContext(ctx context.Context) *slog.Logger { + if logger, ok := ctx.Value(ctxLoggerKey).(*slog.Logger); ok { + return logger + } + return slog.Default() +} + +// With returns a new slog.Logger augmented with the passed key-value pairs, +// and a new context holding this logger. +// +// Typical usage: +// +// logger, ctx := logging.With(ctx, "user_id", userID) +func With(ctx context.Context, args ...any) (*slog.Logger, context.Context) { + logger := FromContext(ctx) + logger = logger.With(args...) + ctx = WithLogger(ctx, logger) + return logger, ctx +} + +// WithGroup returns a new slog.Logger grouped under name, +// and a new context holding this logger. +// +// Typical usage: +// +// logger, ctx := logging.WithGroup(ctx, "http") +func WithGroup(ctx context.Context, name string) (*slog.Logger, context.Context) { + logger := FromContext(ctx) + logger = logger.WithGroup(name) + ctx = WithLogger(ctx, logger) + return logger, ctx +} + +// RecoverAndLog logs a recovered panic or error with a stack trace using the logger from ctx. +// +// Typically used in a deferred recover block: +// +// defer func() { +// if err := recover(); err != nil { +// logging.RecoverAndLog(ctx, "Panic recovered", err) +// os.Exit(1) +// } +// }() +func RecoverAndLog(ctx context.Context, msg string, err any) { + const size = 64 << 10 // 64 KB stack trace buffer + buf := make([]byte, size) + buf = buf[:runtime.Stack(buf, false)] + logger := FromContext(ctx) + logger.ErrorContext(ctx, msg, "Error", err, "stack", string(buf)) +} + +type LogSink string + +const ( + SinkStdout LogSink = "stdout" + SinkStderr LogSink = "stderr" + SinkFile LogSink = "file" +) + +type LogConfig struct { + Level slog.Level `toml:"level"` + Sink LogSink `toml:"sink"` + Format string `toml:"format"` + File string `toml:"file"` +} + +func Setup(cfg LogConfig) error { + var w io.Writer + + switch cfg.Sink { + case SinkStdout, "": + w = os.Stdout + case SinkStderr: + w = os.Stderr + case SinkFile: + if cfg.File == "" { + return fmt.Errorf("log sink set to 'file' but no file path provided") + } + f, err := os.OpenFile(cfg.File, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("failed to open log file: %w", err) + } + w = f + default: + return fmt.Errorf("unsupported log sink: %s", cfg.Sink) + } + + var handler slog.Handler + opts := &slog.HandlerOptions{ + Level: cfg.Level, + } + + switch strings.ToLower(cfg.Format) { + case "text": + handler = slog.NewTextHandler(w, opts) + case "json", "": + handler = slog.NewJSONHandler(w, opts) + default: + return fmt.Errorf("unsupported log format: %s", cfg.Format) + } + + slog.SetDefault(slog.New(handler)) + return nil +} diff --git a/logging/log_test.go b/logging/log_test.go new file mode 100644 index 0000000..93ceaf6 --- /dev/null +++ b/logging/log_test.go @@ -0,0 +1,141 @@ +package logging_test + +import ( + "errors" + "log/slog" + "testing" + + "go.sudomsg.com/kit/logging" + "go.sudomsg.com/kit/logging/test" +) + +func TestWithLogger_And_FromContext(t *testing.T) { + ctx := t.Context() + mock := test.NewMockLogHandler(t) + logger := slog.New(mock) + + ctx = logging.WithLogger(ctx, logger) + + got := logging.FromContext(ctx) + if got != logger { + t.Errorf("expected logger from context to match original logger") + } +} + +func TestFromContext_DefaultFallback(t *testing.T) { + ctx := t.Context() + got := logging.FromContext(ctx) + + if got != slog.Default() { + t.Errorf("expected default logger when no logger is in context") + } +} + +func TestRecoverAndLog(t *testing.T) { + ctx := t.Context() + mock := test.NewMockLogHandler(t) + + logger := slog.New(mock) + ctx = logging.WithLogger(ctx, logger) + + err := errors.New("something broke") + logging.RecoverAndLog(ctx, "panic recovered", err) + + records := mock.Records() + if len(records) != 1 { + t.Fatalf("expected 1 log record, got %d", len(records)) + } + + record := records[0] + if record.Message != "panic recovered" { + t.Errorf("expected message 'panic recAvered', got %q", record.Message) + } + + foundStack := false + record.Attrs(func(a slog.Attr) bool { + if a.Key == "stack" { + foundStack = true + } + return true + }) + + if !foundStack { + t.Errorf("expected 'stack' attribute in log") + } +} + +func TestWith(t *testing.T) { + ctx := t.Context() + + mock := test.NewMockLogHandler(t) + + logger := slog.New(mock) + ctx = logging.WithLogger(ctx, logger) + + user := "user" + id := "1234" + + logger2, newCtx := logging.With(ctx, "user", user, "id", id) + logger2.InfoContext(ctx, "test message") + + records := mock.Records() + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + + record := records[0] + foundUser := false + foundID := false + + record.Attrs(func(attr slog.Attr) bool { + switch attr.Key { + case "user": + foundUser = attr.Value.String() == user + case "id": + foundID = attr.Value.String() == id + } + return true + }) + + if !foundUser || !foundID { + t.Errorf("expected 'user' and 'id' attributes in log record, %v", records) + } + + // Test context carries logger with same attributes + logFromCtx := logging.FromContext(newCtx) + logFromCtx.InfoContext(ctx, "second message") + + if len(mock.Records()) != 2 { + t.Errorf("expected 2 log records, got %d", len(mock.Records())) + } +} + +func TestWithGroup(t *testing.T) { + ctx := t.Context() + + mock := test.NewMockLogHandler(t) + + logger := slog.New(mock) + ctx = logging.WithLogger(ctx, logger) + + logger2, _ := logging.WithGroup(ctx, "foo") + logger2.InfoContext(ctx, "test message", "key", "value") + + records := mock.Records() + if len(records) != 1 { + t.Fatalf("expected 1 record, got %d", len(records)) + } + + record := records[0] + var foundGroup bool + + record.Attrs(func(attr slog.Attr) bool { + foundGroup = attr.Value.Kind() == slog.KindGroup + + return false + }) + + if !foundGroup { + t.Errorf("expected 'user' and 'id' attributes in log record, %v", records) + } +} diff --git a/logging/test/handler.go b/logging/test/handler.go new file mode 100644 index 0000000..d1f5d40 --- /dev/null +++ b/logging/test/handler.go @@ -0,0 +1,112 @@ +package test + +// Package test provides mocks and helpers for testing code that uses slog logging. +// +// It includes MockHandler, a thread-safe slog.Handler implementation for capturing log records in tests. + +import ( + "context" + "log/slog" + "slices" + "sync" + "testing" +) + +type logRecorder struct { + mu sync.Mutex + records []slog.Record +} + +func (h *logRecorder) Append(r slog.Record) { + h.mu.Lock() + defer h.mu.Unlock() + + h.records = append(h.records, r.Clone()) +} + +func (h *logRecorder) Records() []slog.Record { + h.mu.Lock() + defer h.mu.Unlock() + return slices.Clone(h.records) +} + +// MockHandler is a slog.Handler that records log records for testing. +// +// It supports attribute and group chaining like slog's built-in handlers. +// Use NewMockLogHandler to construct one, then pass it to slog.New in your tests. +// +// All recorded logs can be retrieved with the Records method. +type MockHandler struct { + recorder *logRecorder + parent *MockHandler + group string + attrs []slog.Attr +} + +var _ slog.Handler = &MockHandler{} + +// NewMockLogHandler creates a new MockHandler for use in tests. +func NewMockLogHandler(tb testing.TB) *MockHandler { + tb.Helper() + + return &MockHandler{ + recorder: &logRecorder{}, + } +} + +func (h *MockHandler) Enabled(ctx context.Context, level slog.Level) bool { + return true // Capture all logs +} + +func (h *MockHandler) Handle(ctx context.Context, r slog.Record) error { + if h.parent == nil { + r.Clone() + h.recorder.Append(r) + return nil + } + + newRecord := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + + attrs := slices.Clone(h.attrs) + r.Attrs(func(attr slog.Attr) bool { + attrs = append(attrs, attr) + return true + }) + + if h.group != "" { + newRecord.AddAttrs(slog.Attr{ + Key: h.group, + Value: slog.GroupValue(attrs...), + }) + } else { + newRecord.AddAttrs(attrs...) + } + + return h.parent.Handle(ctx, newRecord) +} + +func (h *MockHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &MockHandler{ + recorder: h.recorder, + parent: h, + attrs: attrs, + } +} + +func (h *MockHandler) WithGroup(name string) slog.Handler { + if name == "" { + return h + } + return &MockHandler{ + recorder: h.recorder, + parent: h, + group: name, + } +} + +// Records returns a copy of all slog.Records captured by this handler so far. +// +// It is safe to call from multiple goroutines. +func (h *MockHandler) Records() []slog.Record { + return h.recorder.Records() +} diff --git a/runner/run.go b/runner/run.go new file mode 100644 index 0000000..4527bea --- /dev/null +++ b/runner/run.go @@ -0,0 +1,58 @@ +package runner + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + + "go.sudomsg.com/kit/logging" +) + +// LoadWithArgs initializes context, signal handling, flag parsing, and runs the given application callback. +// +// - args: Command-line arguments (excluding/exact os.Args). +// - run: Callback function (your main app logic). Receives a context (with signal cancellation), +// a FlagSet (ready for use but not yet parsed), and trailing arguments. +// +// Handles panics and logs via logging.RecoverAndLog. +// Logs and exits on non-nil errors from the run callback. +func LoadWithArgs(args []string, run func(ctx context.Context, fs *flag.FlagSet, args []string) error) { + ctx := context.Background() + + ctx, stop := signal.NotifyContext(ctx) + defer stop() + + defer func() { + if err := recover(); err != nil { + logging.RecoverAndLog(ctx, "Panicked", err) + os.Exit(1) + } + }() + + name := "app" + if len(args) > 0 && args[0] != "" { + name = args[0] + args = args[1:] + } + + fs := flag.NewFlagSet(name, flag.ExitOnError) + + if err := run(ctx, fs, args); err != nil { + slog.ErrorContext(ctx, "Program Terminated", "error", err) + os.Exit(1) + } +} + +// LoadWithArgs initializes context, signal handling, flag parsing, and runs the given application callback. +// +// - args: Command-line arguments (excluding/exact os.Args). +// - run: Callback function (your main app logic). Receives a context (with signal cancellation), +// a FlagSet (ready for use but not yet parsed), and trailing arguments. +// +// Handles panics and logs via logging.RecoverAndLog. +// Logs and exits on non-nil errors from the run callback. +func Load(run func(ctx context.Context, fs *flag.FlagSet, args []string) error) { + LoadWithArgs(os.Args, run) +} |