Compare commits

..

5 Commits

Author SHA1 Message Date
2a8c39002d updated hlog 2026-01-13 12:55:30 +11:00
8c2ca4d79a removed trustedhost from hws config 2026-01-13 11:32:29 +11:00
3726ad738a fixed bad import 2026-01-11 23:35:05 +11:00
423a9ee26d updated docs 2026-01-11 23:33:48 +11:00
9f98bbce2d refactored hws to improve database operability 2026-01-11 23:11:49 +11:00
21 changed files with 1428 additions and 91 deletions

43
RULES.md Normal file
View File

@@ -0,0 +1,43 @@
# GOLIB Rules
1. All changes should be documented
Documentation is done in a few ways:
- docstrings
- README.md
- doc.go
- wiki
The README for each module should be laid out as follows:
- Title and description
- Feature list (DO NOT USE EMOTICONS)
- Installation (go get)
- Quick Start (brief example of setting up and using)
- Documentation links to the wiki
- Additional information (e.g. supported databases if package has database features)
- License
- Contributing
- Related projects (if relevant)
Docstrings and doc.go should conform to godoc standards.
Any Config structs with environment variables should have their docstrings match the format
`// ENV ENV_NAME: Description (required <optional condition>) (default: <default value>)`
where the required and default fields are only present if relevant to that variable
The wiki is located at ~/projects/golib-wiki and should be laid out as follows:
- Title and description with version number (i will manually handle commits and versioning)
- Installation
- Key Concepts and features
- Quick start
- Configuration (explicity prefer using ConfigFromEnv for packages that support it)
- Detailed sections on how to use all the features
- Integration (many of the packages in this repo are designed to work in tandem. any close integration with other packages should be mentioned here)
- Best practices
- Troubleshooting
- See also (links to other related or imported packages from this repo)
- Links (GoDoc api link, source code, issue tracker)
2. All features should have tests.
Any changes to existing features or additional features implemented should have tests created and/or updated
3. NEVER COMMIT
I will handle and manage all version control

55
hlog/config.go Normal file
View File

@@ -0,0 +1,55 @@
package hlog
import (
"git.haelnorr.com/h/golib/env"
"github.com/pkg/errors"
)
// Config holds the configuration settings for the logger.
// It can be populated from environment variables using ConfigFromEnv
// or created programmatically.
type Config struct {
LogLevel Level // ENV LOG_LEVEL: Log level for the logger - trace, debug, info, warn, error, fatal, panic (default: info)
LogOutput string // ENV LOG_OUTPUT: Output destination for logs - console, file, or both (default: console)
LogDir string // ENV LOG_DIR: Directory path for log files (required when LOG_OUTPUT is "file" or "both")
LogFileName string // ENV LOG_FILE_NAME: Name of the log file (required when LOG_OUTPUT is "file" or "both")
LogAppend bool // ENV LOG_APPEND: Append to existing log file or overwrite (default: true)
}
// ConfigFromEnv loads logger configuration from environment variables.
//
// Environment variables:
// - LOG_LEVEL: Log level (trace, debug, info, warn, error, fatal, panic) - default: info
// - LOG_OUTPUT: Output destination (console, file, both) - default: console
// - LOG_DIR: Directory for log files (required when LOG_OUTPUT is "file" or "both")
//
// Returns an error if:
// - LOG_LEVEL contains an invalid value
// - LOG_OUTPUT contains an invalid value
// - LogDir or LogFileName is not set and file logging is enabled
func ConfigFromEnv() (*Config, error) {
logLevel, err := LogLevel(env.String("LOG_LEVEL", "info"))
if err != nil {
return nil, errors.Wrap(err, "LogLevel")
}
logOutput := env.String("LOG_OUTPUT", "console")
if logOutput != "both" && logOutput != "console" && logOutput != "file" {
return nil, errors.Errorf("Invalid LOG_OUTPUT: %s", logOutput)
}
cfg := &Config{
LogLevel: logLevel,
LogOutput: logOutput,
LogDir: env.String("LOG_DIR", ""),
LogFileName: env.String("LOG_FILE_NAME", ""),
LogAppend: env.Bool("LOG_APPEND", true),
}
if cfg.LogOutput != "console" {
if cfg.LogDir == "" {
return nil, errors.New("LOG_DIR not set but file logging enabled")
}
if cfg.LogFileName == "" {
return nil, errors.New("LOG_FILE_NAME not set but file logging enabled")
}
}
return cfg, nil
}

181
hlog/config_test.go Normal file
View File

@@ -0,0 +1,181 @@
package hlog
import (
"os"
"testing"
"github.com/rs/zerolog"
)
func TestConfigFromEnv(t *testing.T) {
tests := []struct {
name string
envVars map[string]string
want *Config
wantErr bool
errMsg string
}{
{
name: "default values",
envVars: map[string]string{},
want: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
wantErr: false,
},
{
name: "custom values",
envVars: map[string]string{
"LOG_LEVEL": "debug",
"LOG_OUTPUT": "both",
"LOG_DIR": "/var/log/myapp",
"LOG_FILE_NAME": "application.log",
"LOG_APPEND": "false",
},
want: &Config{
LogLevel: zerolog.DebugLevel,
LogOutput: "both",
LogDir: "/var/log/myapp",
LogFileName: "application.log",
LogAppend: false,
},
wantErr: false,
},
{
name: "file output mode",
envVars: map[string]string{
"LOG_LEVEL": "warn",
"LOG_OUTPUT": "file",
"LOG_DIR": "/tmp/logs",
"LOG_FILE_NAME": "test.log",
"LOG_APPEND": "true",
},
want: &Config{
LogLevel: zerolog.WarnLevel,
LogOutput: "file",
LogDir: "/tmp/logs",
LogFileName: "test.log",
LogAppend: true,
},
wantErr: false,
},
{
name: "invalid log level",
envVars: map[string]string{
"LOG_LEVEL": "invalid",
"LOG_OUTPUT": "console",
},
want: nil,
wantErr: true,
errMsg: "LogLevel",
},
{
name: "invalid log output",
envVars: map[string]string{
"LOG_LEVEL": "info",
"LOG_OUTPUT": "invalid",
},
want: nil,
wantErr: true,
errMsg: "Invalid LOG_OUTPUT",
},
{
name: "trace log level with defaults",
envVars: map[string]string{
"LOG_LEVEL": "trace",
"LOG_OUTPUT": "console",
},
want: &Config{
LogLevel: zerolog.TraceLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
wantErr: false,
},
{
name: "file output without LOG_DIR",
envVars: map[string]string{
"LOG_OUTPUT": "file",
"LOG_FILE_NAME": "test.log",
},
want: nil,
wantErr: true,
errMsg: "LOG_DIR not set",
},
{
name: "file output without LOG_FILE_NAME",
envVars: map[string]string{
"LOG_OUTPUT": "file",
"LOG_DIR": "/tmp",
},
want: nil,
wantErr: true,
errMsg: "LOG_FILE_NAME not set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clear all environment variables first
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_OUTPUT")
os.Unsetenv("LOG_DIR")
os.Unsetenv("LOG_FILE_NAME")
os.Unsetenv("LOG_APPEND")
// Set test environment variables (only set if value provided)
for k, v := range tt.envVars {
os.Setenv(k, v)
}
// Cleanup after test
defer func() {
os.Unsetenv("LOG_LEVEL")
os.Unsetenv("LOG_OUTPUT")
os.Unsetenv("LOG_DIR")
os.Unsetenv("LOG_FILE_NAME")
os.Unsetenv("LOG_APPEND")
}()
got, err := ConfigFromEnv()
if tt.wantErr {
if err == nil {
t.Errorf("ConfigFromEnv() expected error but got nil")
return
}
if tt.errMsg != "" && err.Error() == "" {
t.Errorf("ConfigFromEnv() error = %v, should contain %v", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("ConfigFromEnv() unexpected error = %v", err)
return
}
if got.LogLevel != tt.want.LogLevel {
t.Errorf("ConfigFromEnv() LogLevel = %v, want %v", got.LogLevel, tt.want.LogLevel)
}
if got.LogOutput != tt.want.LogOutput {
t.Errorf("ConfigFromEnv() LogOutput = %v, want %v", got.LogOutput, tt.want.LogOutput)
}
if got.LogDir != tt.want.LogDir {
t.Errorf("ConfigFromEnv() LogDir = %v, want %v", got.LogDir, tt.want.LogDir)
}
if got.LogFileName != tt.want.LogFileName {
t.Errorf("ConfigFromEnv() LogFileName = %v, want %v", got.LogFileName, tt.want.LogFileName)
}
if got.LogAppend != tt.want.LogAppend {
t.Errorf("ConfigFromEnv() LogAppend = %v, want %v", got.LogAppend, tt.want.LogAppend)
}
})
}
}

64
hlog/doc.go Normal file
View File

@@ -0,0 +1,64 @@
// Package hlog provides a structured logging solution built on top of zerolog.
//
// hlog supports multiple output modes (console, file, or both), configurable
// log levels, and automatic log file management. It is designed to be simple
// to configure via environment variables while remaining flexible for
// programmatic configuration.
//
// # Basic Usage
//
// Create a logger with environment-based configuration:
//
// cfg, err := hlog.ConfigFromEnv()
// if err != nil {
// log.Fatal(err)
// }
//
// logger, err := hlog.NewLogger(cfg, os.Stdout)
// if err != nil {
// log.Fatal(err)
// }
// defer logger.CloseLogFile()
//
// logger.Info().Msg("Application started")
//
// # Configuration
//
// hlog can be configured via environment variables:
//
// LOG_LEVEL=info # trace, debug, info, warn, error, fatal, panic (default: info)
// LOG_OUTPUT=console # console, file, or both (default: console)
// LOG_DIR=/var/log/app # Required when LOG_OUTPUT is "file" or "both"
//
// Or programmatically:
//
// cfg := &hlog.Config{
// LogLevel: hlog.InfoLevel,
// LogOutput: "both",
// LogDir: "/var/log/myapp",
// }
//
// # Log Levels
//
// hlog supports the following log levels (from most to least verbose):
// - trace: Very detailed debugging information
// - debug: Detailed debugging information
// - info: General informational messages
// - warn: Warning messages for potentially harmful situations
// - error: Error messages for error events
// - fatal: Fatal messages that will exit the application
// - panic: Panic messages that will panic the application
//
// # Output Modes
//
// - console: Logs to the provided io.Writer (typically os.Stdout or os.Stderr)
// - file: Logs to a file named "server.log" in the configured directory
// - both: Logs to both console and file simultaneously
//
// # File Management
//
// When using file output, hlog creates a file named "server.log" in the
// specified directory. The file is opened in append mode, so logs persist
// across application restarts. Remember to call CloseLogFile() when shutting
// down your application to ensure all logs are flushed.
package hlog

View File

@@ -8,6 +8,7 @@ require (
)
require (
git.haelnorr.com/h/golib/env v0.9.1
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect

View File

@@ -1,3 +1,5 @@
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=

View File

@@ -5,11 +5,21 @@ import (
"github.com/rs/zerolog"
)
// Level is an alias for zerolog.Level, representing the severity of a log message.
type Level = zerolog.Level
// Takes a log level as string and converts it to a Level interface.
// If the string is not a valid input it will return InfoLevel
// Valid levels: trace, debug, info, warn, error, fatal, panic
// LogLevel converts a string to a Level value.
//
// Valid level strings (case-sensitive):
// - "trace": Most verbose, for very detailed debugging
// - "debug": Detailed debugging information
// - "info": General informational messages
// - "warn": Warning messages for potentially harmful situations
// - "error": Error messages for error events
// - "fatal": Fatal messages that will exit the application
// - "panic": Panic messages that will panic the application
//
// Returns an error if the provided string is not a valid log level.
func LogLevel(level string) (Level, error) {
levels := map[string]zerolog.Level{
"trace": zerolog.TraceLevel,

155
hlog/levels_test.go Normal file
View File

@@ -0,0 +1,155 @@
package hlog
import (
"testing"
"github.com/rs/zerolog"
)
func TestLogLevel(t *testing.T) {
tests := []struct {
name string
level string
want Level
wantErr bool
}{
{
name: "trace level",
level: "trace",
want: zerolog.TraceLevel,
wantErr: false,
},
{
name: "debug level",
level: "debug",
want: zerolog.DebugLevel,
wantErr: false,
},
{
name: "info level",
level: "info",
want: zerolog.InfoLevel,
wantErr: false,
},
{
name: "warn level",
level: "warn",
want: zerolog.WarnLevel,
wantErr: false,
},
{
name: "error level",
level: "error",
want: zerolog.ErrorLevel,
wantErr: false,
},
{
name: "fatal level",
level: "fatal",
want: zerolog.FatalLevel,
wantErr: false,
},
{
name: "panic level",
level: "panic",
want: zerolog.PanicLevel,
wantErr: false,
},
{
name: "invalid level",
level: "invalid",
want: 0,
wantErr: true,
},
{
name: "empty string",
level: "",
want: 0,
wantErr: true,
},
{
name: "uppercase level (should fail - case sensitive)",
level: "INFO",
want: 0,
wantErr: true,
},
{
name: "mixed case level (should fail - case sensitive)",
level: "Info",
want: 0,
wantErr: true,
},
{
name: "numeric string",
level: "123",
want: 0,
wantErr: true,
},
{
name: "whitespace",
level: " ",
want: 0,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := LogLevel(tt.level)
if tt.wantErr {
if err == nil {
t.Errorf("LogLevel() expected error but got nil")
}
return
}
if err != nil {
t.Errorf("LogLevel() unexpected error = %v", err)
return
}
if got != tt.want {
t.Errorf("LogLevel() = %v, want %v", got, tt.want)
}
})
}
}
func TestLogLevel_AllValidLevels(t *testing.T) {
// Ensure all valid levels are tested
validLevels := map[string]Level{
"trace": zerolog.TraceLevel,
"debug": zerolog.DebugLevel,
"info": zerolog.InfoLevel,
"warn": zerolog.WarnLevel,
"error": zerolog.ErrorLevel,
"fatal": zerolog.FatalLevel,
"panic": zerolog.PanicLevel,
}
for levelStr, expectedLevel := range validLevels {
t.Run("valid_"+levelStr, func(t *testing.T) {
got, err := LogLevel(levelStr)
if err != nil {
t.Errorf("LogLevel(%s) unexpected error = %v", levelStr, err)
return
}
if got != expectedLevel {
t.Errorf("LogLevel(%s) = %v, want %v", levelStr, got, expectedLevel)
}
})
}
}
func TestLogLevel_ErrorMessage(t *testing.T) {
_, err := LogLevel("invalid")
if err == nil {
t.Fatal("LogLevel() expected error but got nil")
}
expectedMsg := "Invalid log level specified."
if err.Error() != expectedMsg {
t.Errorf("LogLevel() error message = %v, want %v", err.Error(), expectedMsg)
}
}

View File

@@ -7,17 +7,45 @@ import (
"github.com/pkg/errors"
)
// Returns a pointer to a new log file with the specified path.
// Remember to call file.Close() when finished writing to the log file
func NewLogFile(path string) (*os.File, error) {
logPath := filepath.Join(path, "server.log")
file, err := os.OpenFile(
logPath,
os.O_APPEND|os.O_CREATE|os.O_WRONLY,
0663,
)
// newLogFile creates or opens the log file based on the configuration.
// The file is created in the specified directory with the configured filename.
// File permissions are set to 0663 (rw-rw--w-).
//
// If append is true, the file is opened in append mode and new logs are added
// to the end. If append is false, the file is truncated on open, overwriting
// any existing content.
//
// Returns an error if the file cannot be opened or created.
func newLogFile(dir, filename string, append bool) (*os.File, error) {
logPath := filepath.Join(dir, filename)
flags := os.O_CREATE | os.O_WRONLY
if append {
flags |= os.O_APPEND
} else {
flags |= os.O_TRUNC
}
file, err := os.OpenFile(logPath, flags, 0663)
if err != nil {
return nil, errors.Wrap(err, "os.OpenFile")
}
return file, nil
}
// CloseLogFile closes the underlying log file if one is open.
// This should be called when shutting down the application to ensure
// all buffered logs are flushed to disk.
//
// If no log file is open, this is a no-op and returns nil.
// Returns an error if the file cannot be closed.
func (l *Logger) CloseLogFile() error {
if l.logFile == nil {
return nil
}
err := l.logFile.Close()
if err != nil {
return err
}
return nil
}

242
hlog/logfile_test.go Normal file
View File

@@ -0,0 +1,242 @@
package hlog
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestNewLogFile(t *testing.T) {
tests := []struct {
name string
dir string
filename string
append bool
preCreate string // content to pre-create in file
write string // content to write during test
wantErr bool
}{
{
name: "create new file in append mode",
dir: t.TempDir(),
filename: "test.log",
append: true,
write: "test content",
wantErr: false,
},
{
name: "create new file in overwrite mode",
dir: t.TempDir(),
filename: "test.log",
append: false,
write: "test content",
wantErr: false,
},
{
name: "append to existing file",
dir: t.TempDir(),
filename: "existing.log",
append: true,
preCreate: "existing content\n",
write: "new content\n",
wantErr: false,
},
{
name: "overwrite existing file",
dir: t.TempDir(),
filename: "existing.log",
append: false,
preCreate: "old content\n",
write: "new content\n",
wantErr: false,
},
{
name: "invalid directory",
dir: "/nonexistent/invalid/path",
filename: "test.log",
append: true,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logPath := filepath.Join(tt.dir, tt.filename)
// Pre-create file if needed
if tt.preCreate != "" {
err := os.WriteFile(logPath, []byte(tt.preCreate), 0663)
if err != nil {
t.Fatalf("Failed to create pre-existing file: %v", err)
}
}
// Create log file
file, err := newLogFile(tt.dir, tt.filename, tt.append)
if tt.wantErr {
if err == nil {
t.Errorf("newLogFile() expected error but got nil")
if file != nil {
file.Close()
}
}
return
}
if err != nil {
t.Errorf("newLogFile() unexpected error = %v", err)
return
}
if file == nil {
t.Errorf("newLogFile() returned nil file")
return
}
defer file.Close()
// Write test content
if tt.write != "" {
_, err = file.WriteString(tt.write)
if err != nil {
t.Errorf("Failed to write to file: %v", err)
return
}
file.Sync()
}
// Verify file contents
file.Close()
content, err := os.ReadFile(logPath)
if err != nil {
t.Errorf("Failed to read file: %v", err)
return
}
contentStr := string(content)
if tt.append && tt.preCreate != "" {
// In append mode, both old and new content should exist
if !strings.Contains(contentStr, tt.preCreate) {
t.Errorf("Append mode: file missing pre-existing content. Got: %s", contentStr)
}
if !strings.Contains(contentStr, tt.write) {
t.Errorf("Append mode: file missing new content. Got: %s", contentStr)
}
} else if !tt.append && tt.preCreate != "" {
// In overwrite mode, only new content should exist
if strings.Contains(contentStr, tt.preCreate) {
t.Errorf("Overwrite mode: file still contains old content. Got: %s", contentStr)
}
if !strings.Contains(contentStr, tt.write) {
t.Errorf("Overwrite mode: file missing new content. Got: %s", contentStr)
}
} else {
// New file, should only have new content
if !strings.Contains(contentStr, tt.write) {
t.Errorf("New file: missing expected content. Got: %s", contentStr)
}
}
})
}
}
func TestNewLogFile_Permissions(t *testing.T) {
tempDir := t.TempDir()
filename := "permissions_test.log"
file, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file.Close()
logPath := filepath.Join(tempDir, filename)
_, err = os.Stat(logPath)
if err != nil {
t.Fatalf("Failed to stat file: %v", err)
}
// Note: Actual file permissions may differ from requested permissions
// due to umask settings, so we just verify the file was created
// The OS will apply umask to the requested 0663 permissions
}
func TestNewLogFile_MultipleAppends(t *testing.T) {
tempDir := t.TempDir()
filename := "multiple_appends.log"
messages := []string{
"first message\n",
"second message\n",
"third message\n",
}
// Write messages sequentially
for _, msg := range messages {
file, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
_, err = file.WriteString(msg)
if err != nil {
t.Fatalf("WriteString() error = %v", err)
}
file.Close()
}
// Verify all messages are present
logPath := filepath.Join(tempDir, filename)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
contentStr := string(content)
for _, msg := range messages {
if !strings.Contains(contentStr, msg) {
t.Errorf("File missing message: %s. Got: %s", msg, contentStr)
}
}
}
func TestNewLogFile_OverwriteClears(t *testing.T) {
tempDir := t.TempDir()
filename := "overwrite_clear.log"
// Create file with initial content
initialContent := "this should be removed\n"
file1, err := newLogFile(tempDir, filename, true)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file1.WriteString(initialContent)
file1.Close()
// Open in overwrite mode
newContent := "new content only\n"
file2, err := newLogFile(tempDir, filename, false)
if err != nil {
t.Fatalf("newLogFile() error = %v", err)
}
file2.WriteString(newContent)
file2.Close()
// Verify only new content exists
logPath := filepath.Join(tempDir, filename)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read file: %v", err)
}
contentStr := string(content)
if strings.Contains(contentStr, initialContent) {
t.Errorf("File still contains initial content after overwrite. Got: %s", contentStr)
}
if !strings.Contains(contentStr, newContent) {
t.Errorf("File missing new content. Got: %s", contentStr)
}
}

View File

@@ -9,17 +9,42 @@ import (
"github.com/rs/zerolog/pkgerrors"
)
type Logger = zerolog.Logger
// Logger wraps a zerolog.Logger and manages an optional log file.
// It embeds *zerolog.Logger, so all zerolog methods are available directly.
type Logger struct {
*zerolog.Logger
logFile *os.File
}
// Get a pointer to a new zerolog.Logger with the specified level and output
// Can provide a file, writer or both. Must provide at least one of the two
// NewLogger creates a new Logger instance based on the provided configuration.
//
// The logger output depends on cfg.LogOutput:
// - "console": Logs to the provided io.Writer w
// - "file": Logs to a file in cfg.LogDir (w can be nil)
// - "both": Logs to both the io.Writer and a file
//
// When file logging is enabled, cfg.LogDir must be set to a valid directory path.
// The log file will be named "server.log" and placed in that directory.
//
// The logger is configured with:
// - Unix timestamp format
// - Error stack trace marshaling
// - Log level from cfg.LogLevel
//
// Returns an error if:
// - cfg is nil
// - w is nil when cfg.LogOutput is not "file"
// - cfg.LogDir is empty when file logging is enabled
// - cfg.LogFileName is empty when file logging is enabled
// - The log file cannot be created
func NewLogger(
logLevel zerolog.Level,
cfg *Config,
w io.Writer,
logFile *os.File,
logDir string,
) (*Logger, error) {
if w == nil && logFile == nil {
if cfg == nil {
return nil, errors.New("No config provided")
}
if w == nil && cfg.LogOutput != "file" {
return nil, errors.New("No Writer provided for log output.")
}
@@ -31,6 +56,21 @@ func NewLogger(
consoleWriter = zerolog.ConsoleWriter{Out: w}
}
var logFile *os.File
var err error
if cfg.LogOutput == "file" || cfg.LogOutput == "both" {
if cfg.LogDir == "" {
return nil, errors.New("LOG_DIR must be set when LOG_OUTPUT is 'file' or 'both'")
}
if cfg.LogFileName == "" {
return nil, errors.New("LOG_FILE_NAME must be set when LOG_OUTPUT is 'file' or 'both'")
}
logFile, err = newLogFile(cfg.LogDir, cfg.LogFileName, cfg.LogAppend)
if err != nil {
return nil, errors.Wrap(err, "newLogFile")
}
}
var output io.Writer
if logFile != nil {
if w != nil {
@@ -41,11 +81,17 @@ func NewLogger(
} else {
output = consoleWriter
}
logger := zerolog.New(output).
With().
Timestamp().
Logger().
Level(logLevel)
Level(cfg.LogLevel)
return &logger, nil
hlog := &Logger{
Logger: &logger,
logFile: logFile,
}
return hlog, nil
}

376
hlog/logger_test.go Normal file
View File

@@ -0,0 +1,376 @@
package hlog
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/rs/zerolog"
)
func TestNewLogger(t *testing.T) {
tests := []struct {
name string
cfg *Config
writer io.Writer
wantErr bool
errMsg string
}{
{
name: "console output only",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
LogDir: "",
LogFileName: "",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: false,
},
{
name: "nil config",
cfg: nil,
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "No config provided",
},
{
name: "nil writer for both output",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
},
writer: nil,
wantErr: true,
errMsg: "No Writer provided",
},
{
name: "file output without LogDir",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: "",
LogFileName: "test.log",
LogAppend: true,
},
writer: nil,
wantErr: true,
errMsg: "LOG_DIR must be set",
},
{
name: "file output without LogFileName",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: "/tmp",
LogFileName: "",
LogAppend: true,
},
writer: nil,
wantErr: true,
errMsg: "LOG_FILE_NAME must be set",
},
{
name: "both output without LogDir",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
LogDir: "",
LogFileName: "test.log",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "LOG_DIR must be set",
},
{
name: "both output without LogFileName",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "both",
LogDir: "/tmp",
LogFileName: "",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: true,
errMsg: "LOG_FILE_NAME must be set",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, err := NewLogger(tt.cfg, tt.writer)
if tt.wantErr {
if err == nil {
t.Errorf("NewLogger() expected error but got nil")
return
}
if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("NewLogger() error = %v, should contain %v", err, tt.errMsg)
}
return
}
if err != nil {
t.Errorf("NewLogger() unexpected error = %v", err)
return
}
if logger == nil {
t.Errorf("NewLogger() returned nil logger")
return
}
if logger.Logger == nil {
t.Errorf("NewLogger() returned logger with nil zerolog.Logger")
}
})
}
}
func TestNewLogger_FileOutput(t *testing.T) {
// Create temporary directory for test logs
tempDir := t.TempDir()
tests := []struct {
name string
cfg *Config
writer io.Writer
wantErr bool
checkFile bool
logMessage string
}{
{
name: "file output with append",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "append_test.log",
LogAppend: true,
},
writer: nil,
wantErr: false,
checkFile: true,
logMessage: "test append message",
},
{
name: "file output with overwrite",
cfg: &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "overwrite_test.log",
LogAppend: false,
},
writer: nil,
wantErr: false,
checkFile: true,
logMessage: "test overwrite message",
},
{
name: "both output modes",
cfg: &Config{
LogLevel: zerolog.DebugLevel,
LogOutput: "both",
LogDir: tempDir,
LogFileName: "both_test.log",
LogAppend: true,
},
writer: bytes.NewBuffer(nil),
wantErr: false,
checkFile: true,
logMessage: "test both message",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
logger, err := NewLogger(tt.cfg, tt.writer)
if tt.wantErr {
if err == nil {
t.Errorf("NewLogger() expected error but got nil")
}
return
}
if err != nil {
t.Errorf("NewLogger() unexpected error = %v", err)
return
}
if logger == nil {
t.Errorf("NewLogger() returned nil logger")
return
}
// Log a test message
logger.Info().Msg(tt.logMessage)
// Close the log file to flush
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() error = %v", err)
}
// Check if file exists and contains message
if tt.checkFile {
logPath := filepath.Join(tt.cfg.LogDir, tt.cfg.LogFileName)
content, err := os.ReadFile(logPath)
if err != nil {
t.Errorf("Failed to read log file: %v", err)
return
}
if !strings.Contains(string(content), tt.logMessage) {
t.Errorf("Log file doesn't contain expected message. Got: %s", string(content))
}
}
// Check console output for "both" mode
if tt.cfg.LogOutput == "both" && tt.writer != nil {
if buf, ok := tt.writer.(*bytes.Buffer); ok {
consoleOutput := buf.String()
if !strings.Contains(consoleOutput, tt.logMessage) {
t.Errorf("Console output doesn't contain expected message. Got: %s", consoleOutput)
}
}
}
})
}
}
func TestNewLogger_AppendVsOverwrite(t *testing.T) {
tempDir := t.TempDir()
logFileName := "append_vs_overwrite.log"
// First logger - write initial content
cfg1 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: true,
}
logger1, err := NewLogger(cfg1, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger1.Info().Msg("first message")
logger1.CloseLogFile()
// Second logger - append mode
cfg2 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: true,
}
logger2, err := NewLogger(cfg2, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger2.Info().Msg("second message")
logger2.CloseLogFile()
// Check both messages exist
logPath := filepath.Join(tempDir, logFileName)
content, err := os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
contentStr := string(content)
if !strings.Contains(contentStr, "first message") {
t.Errorf("Log file missing 'first message' after append")
}
if !strings.Contains(contentStr, "second message") {
t.Errorf("Log file missing 'second message' after append")
}
// Third logger - overwrite mode
cfg3 := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: logFileName,
LogAppend: false,
}
logger3, err := NewLogger(cfg3, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
logger3.Info().Msg("third message")
logger3.CloseLogFile()
// Check only third message exists
content, err = os.ReadFile(logPath)
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
contentStr = string(content)
if strings.Contains(contentStr, "first message") {
t.Errorf("Log file still contains 'first message' after overwrite")
}
if strings.Contains(contentStr, "second message") {
t.Errorf("Log file still contains 'second message' after overwrite")
}
if !strings.Contains(contentStr, "third message") {
t.Errorf("Log file missing 'third message' after overwrite")
}
}
func TestLogger_CloseLogFile(t *testing.T) {
t.Run("close with file", func(t *testing.T) {
tempDir := t.TempDir()
cfg := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "file",
LogDir: tempDir,
LogFileName: "close_test.log",
LogAppend: true,
}
logger, err := NewLogger(cfg, nil)
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() error = %v", err)
}
})
t.Run("close without file", func(t *testing.T) {
cfg := &Config{
LogLevel: zerolog.InfoLevel,
LogOutput: "console",
}
logger, err := NewLogger(cfg, bytes.NewBuffer(nil))
if err != nil {
t.Fatalf("NewLogger() error = %v", err)
}
err = logger.CloseLogFile()
if err != nil {
t.Errorf("CloseLogFile() should not error when no file is open, got: %v", err)
}
})
}

2
hws/.gitignore vendored
View File

@@ -17,3 +17,5 @@ coverage.html
# Go workspace file
go.work
.claude/

21
hws/LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 haelnorr
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.

119
hws/README.md Normal file
View File

@@ -0,0 +1,119 @@
# HWS (H Web Server)
[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/golib/hws.svg)](https://pkg.go.dev/git.haelnorr.com/h/golib/hws)
A lightweight, opinionated HTTP web server framework for Go built on top of the standard library's `net/http`.
## Features
- 🚀 Built on Go 1.22+ routing patterns with method and path matching
- 🎯 Structured error handling with customizable error pages
- 📝 Integrated logging with zerolog via hlog
- 🔧 Middleware support with predictable execution order
- 🗜️ GZIP compression support
- 🔒 Safe static file serving (prevents directory listing)
- ⚙️ Environment variable configuration
- ⏱️ Request timing and logging middleware
- 💚 Graceful shutdown support
- 🏥 Built-in health check endpoint
## Installation
```bash
go get git.haelnorr.com/h/golib/hws
```
## Quick Start
```go
package main
import (
"context"
"git.haelnorr.com/h/golib/hws"
"net/http"
)
func main() {
// Load configuration from environment variables
config, _ := hws.ConfigFromEnv()
// Create server
server, _ := hws.NewServer(config)
// Define routes
routes := []hws.Route{
{
Path: "/",
Method: hws.MethodGET,
Handler: http.HandlerFunc(homeHandler),
},
{
Path: "/api/users/{id}",
Method: hws.MethodGET,
Handler: http.HandlerFunc(getUserHandler),
},
}
// Add routes and middleware
server.AddRoutes(routes...)
server.AddMiddleware()
// Start server
ctx := context.Background()
server.Start(ctx)
// Wait for server to be ready
<-server.Ready()
}
func homeHandler(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("Hello, World!"))
}
func getUserHandler(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Write([]byte("User ID: " + id))
}
```
## Documentation
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hws).
### Key Topics
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hws#configuration)
- [Routing](https://git.haelnorr.com/h/golib/wiki/hws#routing)
- [Middleware](https://git.haelnorr.com/h/golib/wiki/hws#middleware)
- [Error Handling](https://git.haelnorr.com/h/golib/wiki/hws#error-handling)
- [Logging](https://git.haelnorr.com/h/golib/wiki/hws#logging)
- [Static Files](https://git.haelnorr.com/h/golib/wiki/hws#static-files)
- [Graceful Shutdown](https://git.haelnorr.com/h/golib/wiki/hws#graceful-shutdown)
- [Complete Examples](https://git.haelnorr.com/h/golib/wiki/hws#complete-production-example)
## Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `HWS_HOST` | Host to listen on | `127.0.0.1` |
| `HWS_PORT` | Port to listen on | `3000` |
| `HWS_TRUSTED_HOST` | Trusted hostname/domain | Same as Host |
| `HWS_GZIP` | Enable GZIP compression | `false` |
| `HWS_READ_HEADER_TIMEOUT` | Header read timeout (seconds) | `2` |
| `HWS_WRITE_TIMEOUT` | Write timeout (seconds) | `10` |
| `HWS_IDLE_TIMEOUT` | Idle connection timeout (seconds) | `120` |
## License
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## Related Projects
- [HWSAuth](https://git.haelnorr.com/h/golib/hwsauth) - JWT authentication middleware for HWS
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation

View File

@@ -9,7 +9,6 @@ import (
type Config struct {
Host string // ENV HWS_HOST: Host to listen on (default: 127.0.0.1)
Port uint64 // ENV HWS_PORT: Port to listen on (default: 3000)
TrustedHost string // ENV HWS_TRUSTED_HOST: Domain/Hostname to accept as trusted (default: same as Host)
GZIP bool // ENV HWS_GZIP: Flag for GZIP compression on requests (default: false)
ReadHeaderTimeout time.Duration // ENV HWS_READ_HEADER_TIMEOUT: Timeout for reading request headers in seconds (default: 2)
WriteTimeout time.Duration // ENV HWS_WRITE_TIMEOUT: Timeout for writing requests in seconds (default: 10)
@@ -18,13 +17,9 @@ type Config struct {
// ConfigFromEnv returns a Config struct loaded from the environment variables
func ConfigFromEnv() (*Config, error) {
host := env.String("HWS_HOST", "127.0.0.1")
trustedHost := env.String("HWS_TRUSTED_HOST", host)
cfg := &Config{
Host: host,
Host: env.String("HWS_HOST", "127.0.0.1"),
Port: env.UInt64("HWS_PORT", 3000),
TrustedHost: trustedHost,
GZIP: env.Bool("HWS_GZIP", false),
ReadHeaderTimeout: time.Duration(env.Int("HWS_READ_HEADER_TIMEOUT", 2)) * time.Second,
WriteTimeout: time.Duration(env.Int("HWS_WRITE_TIMEOUT", 10)) * time.Second,

View File

@@ -15,7 +15,6 @@ func Test_ConfigFromEnv(t *testing.T) {
// Clear any existing env vars
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -27,7 +26,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, uint64(3000), config.Port)
assert.Equal(t, "127.0.0.1", config.TrustedHost)
assert.Equal(t, false, config.GZIP)
assert.Equal(t, 2*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 10*time.Second, config.WriteTimeout)
@@ -41,7 +39,6 @@ func Test_ConfigFromEnv(t *testing.T) {
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "192.168.1.1", config.Host)
assert.Equal(t, "192.168.1.1", config.TrustedHost) // Should match host by default
})
t.Run("Custom port", func(t *testing.T) {
@@ -53,18 +50,6 @@ func Test_ConfigFromEnv(t *testing.T) {
assert.Equal(t, uint64(8080), config.Port)
})
t.Run("Custom trusted host", func(t *testing.T) {
os.Setenv("HWS_HOST", "127.0.0.1")
os.Setenv("HWS_TRUSTED_HOST", "example.com")
defer os.Unsetenv("HWS_HOST")
defer os.Unsetenv("HWS_TRUSTED_HOST")
config, err := hws.ConfigFromEnv()
require.NoError(t, err)
assert.Equal(t, "127.0.0.1", config.Host)
assert.Equal(t, "example.com", config.TrustedHost)
})
t.Run("GZIP enabled", func(t *testing.T) {
os.Setenv("HWS_GZIP", "true")
defer os.Unsetenv("HWS_GZIP")
@@ -92,7 +77,6 @@ func Test_ConfigFromEnv(t *testing.T) {
t.Run("All custom values", func(t *testing.T) {
os.Setenv("HWS_HOST", "0.0.0.0")
os.Setenv("HWS_PORT", "9000")
os.Setenv("HWS_TRUSTED_HOST", "myapp.com")
os.Setenv("HWS_GZIP", "true")
os.Setenv("HWS_READ_HEADER_TIMEOUT", "3")
os.Setenv("HWS_WRITE_TIMEOUT", "15")
@@ -100,7 +84,6 @@ func Test_ConfigFromEnv(t *testing.T) {
defer func() {
os.Unsetenv("HWS_HOST")
os.Unsetenv("HWS_PORT")
os.Unsetenv("HWS_TRUSTED_HOST")
os.Unsetenv("HWS_GZIP")
os.Unsetenv("HWS_READ_HEADER_TIMEOUT")
os.Unsetenv("HWS_WRITE_TIMEOUT")
@@ -111,7 +94,6 @@ func Test_ConfigFromEnv(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "0.0.0.0", config.Host)
assert.Equal(t, uint64(9000), config.Port)
assert.Equal(t, "myapp.com", config.TrustedHost)
assert.Equal(t, true, config.GZIP)
assert.Equal(t, 3*time.Second, config.ReadHeaderTimeout)
assert.Equal(t, 15*time.Second, config.WriteTimeout)

View File

@@ -18,7 +18,7 @@ func Test_Server_Addr(t *testing.T) {
Port: 8080,
})
require.NoError(t, err)
addr := server.Addr()
assert.Equal(t, "192.168.1.1:8080", addr)
}
@@ -26,7 +26,7 @@ func Test_Server_Addr(t *testing.T) {
func Test_Server_Handler(t *testing.T) {
var buf bytes.Buffer
server := createTestServer(t, &buf)
// Add routes first
handler := testHandler
err := server.AddRoutes(hws.Route{
@@ -35,16 +35,16 @@ func Test_Server_Handler(t *testing.T) {
Handler: handler,
})
require.NoError(t, err)
// Get the handler
h := server.Handler()
require.NotNil(t, h)
// Test the handler directly with httptest
req := httptest.NewRequest("GET", "/test", nil)
rr := httptest.NewRecorder()
h.ServeHTTP(rr, req)
assert.Equal(t, 200, rr.Code)
assert.Equal(t, "hello world", rr.Body.String())
}
@@ -52,7 +52,7 @@ func Test_Server_Handler(t *testing.T) {
func Test_LoggerIgnorePaths_Integration(t *testing.T) {
var buf bytes.Buffer
server := createTestServer(t, &buf)
// Add routes
err := server.AddRoutes(hws.Route{
Path: "/test",
@@ -64,28 +64,28 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
Handler: testHandler,
})
require.NoError(t, err)
// Set paths to ignore
server.LoggerIgnorePaths("/ignore", "/healthz")
err = server.AddMiddleware()
require.NoError(t, err)
// Test that ignored path doesn't generate logs
buf.Reset()
req := httptest.NewRequest("GET", "/ignore", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
// Buffer should be empty for ignored path
assert.Empty(t, buf.String())
// Test that non-ignored path generates logs
buf.Reset()
req = httptest.NewRequest("GET", "/test", nil)
rr = httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
// Buffer should have logs for non-ignored path
assert.NotEmpty(t, buf.String())
}
@@ -93,12 +93,12 @@ func Test_LoggerIgnorePaths_Integration(t *testing.T) {
func Test_WrappedWriter(t *testing.T) {
var buf bytes.Buffer
server := createTestServer(t, &buf)
// Add routes with different status codes
err := server.AddRoutes(
hws.Route{
Path: "/ok",
Method: hws.MethodGET,
Path: "/ok",
Method: hws.MethodGET,
Handler: testHandler,
},
hws.Route{
@@ -111,16 +111,16 @@ func Test_WrappedWriter(t *testing.T) {
},
)
require.NoError(t, err)
err = server.AddMiddleware()
require.NoError(t, err)
// Test OK status
req := httptest.NewRequest("GET", "/ok", nil)
rr := httptest.NewRecorder()
server.Handler().ServeHTTP(rr, req)
assert.Equal(t, 200, rr.Code)
// Test Created status
req = httptest.NewRequest("POST", "/created", nil)
rr = httptest.NewRecorder()
@@ -149,7 +149,7 @@ func Test_Start_Errors(t *testing.T) {
})
require.NoError(t, err)
err = server.Start(nil)
err = server.Start(t.Context())
assert.Error(t, err)
assert.Contains(t, err.Error(), "Context cannot be nil")
})
@@ -163,10 +163,10 @@ func Test_Shutdown_Errors(t *testing.T) {
startTestServer(t, server)
<-server.Ready()
err := server.Shutdown(nil)
err := server.Shutdown(t.Context())
assert.Error(t, err)
assert.Contains(t, err.Error(), "Context cannot be nil")
// Clean up
server.Shutdown(t.Context())
})
@@ -199,7 +199,7 @@ func Test_WaitUntilReady_ContextCancelled(t *testing.T) {
// Start should return with context error since timeout is so short
err = server.Start(ctx)
// The error could be nil if server started very quickly, or context.DeadlineExceeded
// This tests the ctx.Err() path in waitUntilReady
if err != nil {

View File

@@ -1,8 +1,8 @@
# hwsauth
# HWSAuth
[![Go Reference](https://pkg.go.dev/badge/git.haelnorr.com/h/golib/hwsauth.svg)](https://pkg.go.dev/git.haelnorr.com/h/golib/hwsauth)
JWT-based authentication middleware for the [hws](https://git.haelnorr.com/h/golib/hws) web framework.
JWT-based authentication middleware for the [HWS](https://git.haelnorr.com/h/golib/hws) web framework.
## Features
@@ -65,8 +65,9 @@ func main() {
return user, err
}
// Create HWS server
server := hws.NewServer(":8080", logger)
// Create server
serverCfg, _ := hws.ConfigFromEnv()
server, _ := hws.NewServer(serverCfg)
// Create authenticator
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
@@ -78,17 +79,28 @@ func main() {
errorPageFunc,
)
// Define routes
routes := []hws.Route{
{
Path: "/dashboard",
Method: hws.MethodGET,
Handler: auth.LoginReq(http.HandlerFunc(dashboardHandler)),
},
}
server.AddRoutes(routes...)
// Add authentication middleware
server.AddMiddleware(auth.Authenticate())
// Optionally ignore public paths
auth.IgnorePaths("/", "/login", "/register", "/static")
// Protect routes
protectedHandler := auth.LoginReq(http.HandlerFunc(dashboardHandler))
server.AddRoute("GET", "/dashboard", protectedHandler)
server.Start()
// Start server
ctx := context.Background()
server.Start(ctx)
<-server.Ready()
}
```

View File

@@ -2,26 +2,24 @@ module git.haelnorr.com/h/golib/hwsauth
go 1.25.5
replace git.haelnorr.com/h/golib/hws => ../hws
require (
git.haelnorr.com/h/golib/cookies v0.9.0
git.haelnorr.com/h/golib/env v0.9.1
git.haelnorr.com/h/golib/hws v0.1.0
git.haelnorr.com/h/golib/hws v0.2.0
git.haelnorr.com/h/golib/jwt v0.10.0
github.com/pkg/errors v0.9.1
github.com/rs/zerolog v1.34.0
)
require (
git.haelnorr.com/h/golib/hlog v0.9.0 // indirect
git.haelnorr.com/h/golib/hlog v0.9.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
golang.org/x/sys v0.12.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
golang.org/x/sys v0.40.0 // indirect
k8s.io/apimachinery v0.35.0 // indirect
k8s.io/klog/v2 v2.130.1 // indirect
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect
k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect
)

View File

@@ -2,8 +2,10 @@ git.haelnorr.com/h/golib/cookies v0.9.0 h1:Vf+eX1prHkKuGrQon1BHY87yaPc1H+HJFRXDO
git.haelnorr.com/h/golib/cookies v0.9.0/go.mod h1:y1385YExI9gLwckCVDCYVcsFXr6N7T3brJjnJD2QIuo=
git.haelnorr.com/h/golib/env v0.9.1 h1:2Vsj+mJKnO5f1Md1GO5v9ggLN5zWa0baCewcSHTjoNY=
git.haelnorr.com/h/golib/env v0.9.1/go.mod h1:glUQVdA1HMKX1avTDyTyuhcr36SSxZtlJxKDT5KTztg=
git.haelnorr.com/h/golib/hlog v0.9.0 h1:ib8n2MdmiRK2TF067p220kXmhDe9aAnlcsgpuv+QpvE=
git.haelnorr.com/h/golib/hlog v0.9.0/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
git.haelnorr.com/h/golib/hlog v0.9.1 h1:9VmE/IQTfD8LAEyTbUCZLy/+8PbcHA1Kob/WQHRHKzc=
git.haelnorr.com/h/golib/hlog v0.9.1/go.mod h1:oOlzb8UVHUYP1k7dN5PSJXVskAB2z8EYgRN85jAi0Zk=
git.haelnorr.com/h/golib/hws v0.2.0 h1:MR2Tu2qPaW+/oK8aXFJLRFaYZIHgKiex3t3zE41cu1U=
git.haelnorr.com/h/golib/hws v0.2.0/go.mod h1:6ZlRKnt8YMpv5XcMXmyBGmD1/euvBo3d1azEvHJjOLo=
git.haelnorr.com/h/golib/jwt v0.10.0 h1:8cI8mSnb8X+EmJtrBO/5UZwuBMtib0IE9dv85gkm94E=
git.haelnorr.com/h/golib/jwt v0.10.0/go.mod h1:fbuPrfucT9lL0faV5+Q5Gk9WFJxPlwzRPpbMQKYZok4=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
@@ -18,11 +20,13 @@ github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keL
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
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=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -34,13 +38,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8=
k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns=
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck=
k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY=
k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=