Compare commits
17 Commits
hws/v0.2.1
...
tmdb/v0.9.
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd6b7a57c | |||
| 1a099a3724 | |||
| 7c91cbb08a | |||
| 1c66e6dd66 | |||
| 614be4ed0e | |||
| da8e3c2d10 | |||
| 51045537b2 | |||
| bdae21ec0b | |||
| ddd570230b | |||
| a255ee578e | |||
| 1b1fa12a45 | |||
| 90976ca98b | |||
| 328adaadee | |||
| 5be9811afc | |||
| 52341aba56 | |||
| 7471ae881b | |||
| 2a8c39002d |
46
RULES.md
Normal file
46
RULES.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# 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 with version number
|
||||
- Feature list (DO NOT USE EMOTICONS)
|
||||
- Installation (go get)
|
||||
- Quick Start (brief example of setting up and using)
|
||||
- Documentation links to the wiki (path is `../golib/wiki/<package>.md`)
|
||||
- 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:
|
||||
- Link to wiki page from the Home page
|
||||
- Title and description with version number
|
||||
- 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. Version control
|
||||
Version numbers are specified using git tags.
|
||||
Do not change version numbers. When updating documentation, append the branch name to the version number.
|
||||
Changes made to the golib-wiki repo should be made under the same branch name as the changes made in this repo
|
||||
21
hlog/LICENSE
Normal file
21
hlog/LICENSE
Normal 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.
|
||||
73
hlog/README.md
Normal file
73
hlog/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# HLog - v0.10.3
|
||||
|
||||
A structured logging package for Go built on top of [zerolog](https://github.com/rs/zerolog). HLog provides simple configuration via environment variables, flexible output options, and automatic log file management.
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple output modes: console, file, or both simultaneously
|
||||
- Configurable log levels: trace, debug, info, warn, error, fatal, panic
|
||||
- Environment variable-based configuration with ConfigFromEnv
|
||||
- Automatic log file management with append or overwrite modes
|
||||
- Built on zerolog for high performance and structured logging
|
||||
- Error stack trace support via pkg/errors integration
|
||||
- Unix timestamp format
|
||||
- Console-friendly output formatting
|
||||
- Multi-writer support for simultaneous console and file output
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get git.haelnorr.com/h/golib/hlog
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Load configuration from environment variables
|
||||
cfg, err := hlog.ConfigFromEnv()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create a new logger
|
||||
logger, err := hlog.NewLogger(cfg, os.Stdout)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer logger.CloseLogFile()
|
||||
|
||||
// Start logging
|
||||
logger.Info().Msg("Application started")
|
||||
logger.Debug().Str("user", "john").Msg("User logged in")
|
||||
logger.Error().Err(err).Msg("Something went wrong")
|
||||
}
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed documentation, see the [HLog Wiki](https://git.haelnorr.com/h/golib/wiki/HLog.md).
|
||||
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hlog).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [env](https://git.haelnorr.com/h/golib/env) - Environment variable helper used by hlog for configuration
|
||||
- [zerolog](https://github.com/rs/zerolog) - The underlying logging library
|
||||
55
hlog/config.go
Normal file
55
hlog/config.go
Normal 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
181
hlog/config_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
82
hlog/doc.go
Normal file
82
hlog/doc.go
Normal file
@@ -0,0 +1,82 @@
|
||||
// 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 using ConfigFromEnv:
|
||||
//
|
||||
// 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"
|
||||
// LOG_FILE_NAME=server.log # Required when LOG_OUTPUT is "file" or "both"
|
||||
// LOG_APPEND=true # Append to existing file or overwrite (default: true)
|
||||
//
|
||||
// Or programmatically:
|
||||
//
|
||||
// cfg := &hlog.Config{
|
||||
// LogLevel: hlog.InfoLevel,
|
||||
// LogOutput: "both",
|
||||
// LogDir: "/var/log/myapp",
|
||||
// LogFileName: "server.log",
|
||||
// LogAppend: true,
|
||||
// }
|
||||
//
|
||||
// # 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 in the configured directory
|
||||
// - both: Logs to both console and file simultaneously using zerolog.MultiLevelWriter
|
||||
//
|
||||
// # File Management
|
||||
//
|
||||
// When using file output, hlog creates a file with the specified name in the
|
||||
// configured directory. The file can be opened in append mode (default) to
|
||||
// preserve logs across application restarts, or in overwrite mode to start
|
||||
// fresh each time. Remember to call CloseLogFile() when shutting down your
|
||||
// application to ensure all logs are flushed to disk.
|
||||
//
|
||||
// # Error Stack Traces
|
||||
//
|
||||
// hlog automatically configures zerolog to include stack traces for errors
|
||||
// wrapped with github.com/pkg/errors. This provides detailed error context
|
||||
// when using errors.Wrap or errors.WithStack.
|
||||
//
|
||||
// # Integration
|
||||
//
|
||||
// hlog integrates with:
|
||||
// - git.haelnorr.com/h/golib/env: For environment variable configuration
|
||||
// - github.com/rs/zerolog: The underlying logging implementation
|
||||
// - github.com/pkg/errors: For error stack trace support
|
||||
package hlog
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
155
hlog/levels_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
242
hlog/logfile_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
376
hlog/logger_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,21 +1,19 @@
|
||||
# HWS (H Web Server)
|
||||
# HWS (H Web Server) - v0.2.2
|
||||
|
||||
[](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`.
|
||||
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
|
||||
- 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 with ConfigFromEnv
|
||||
- Request timing and logging middleware
|
||||
- Graceful shutdown support
|
||||
- Built-in health check endpoint
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -30,8 +28,8 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"net/http"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -79,34 +77,13 @@ func getUserHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hws).
|
||||
For detailed documentation, see the [HWS Wiki](https://git.haelnorr.com/h/golib/wiki/HWS.md).
|
||||
|
||||
### 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` |
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hws).
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -114,6 +91,6 @@ 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
|
||||
- [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
|
||||
|
||||
144
hws/doc.go
Normal file
144
hws/doc.go
Normal file
@@ -0,0 +1,144 @@
|
||||
// Package hws provides a lightweight HTTP web server framework built on top of Go's standard library.
|
||||
//
|
||||
// HWS (H Web Server) is an opinionated framework that leverages Go 1.22+ routing patterns
|
||||
// with built-in middleware, structured error handling, and production-ready defaults. It
|
||||
// integrates seamlessly with other golib packages like hlog for logging and hwsauth for
|
||||
// authentication.
|
||||
//
|
||||
// # Basic Usage
|
||||
//
|
||||
// Create a server with environment-based configuration:
|
||||
//
|
||||
// cfg, err := hws.ConfigFromEnv()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// server, err := hws.NewServer(cfg)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// routes := []hws.Route{
|
||||
// {
|
||||
// Path: "/",
|
||||
// Method: hws.MethodGET,
|
||||
// Handler: http.HandlerFunc(homeHandler),
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// server.AddRoutes(routes...)
|
||||
// server.AddMiddleware()
|
||||
//
|
||||
// ctx := context.Background()
|
||||
// server.Start(ctx)
|
||||
//
|
||||
// <-server.Ready()
|
||||
//
|
||||
// # Configuration
|
||||
//
|
||||
// HWS can be configured via environment variables using ConfigFromEnv:
|
||||
//
|
||||
// HWS_HOST=127.0.0.1 # Host to listen on (default: 127.0.0.1)
|
||||
// HWS_PORT=3000 # Port to listen on (default: 3000)
|
||||
// HWS_GZIP=false # Enable GZIP compression (default: false)
|
||||
// HWS_READ_HEADER_TIMEOUT=2 # Header read timeout in seconds (default: 2)
|
||||
// HWS_WRITE_TIMEOUT=10 # Write timeout in seconds (default: 10)
|
||||
// HWS_IDLE_TIMEOUT=120 # Idle connection timeout in seconds (default: 120)
|
||||
//
|
||||
// Or programmatically:
|
||||
//
|
||||
// cfg := &hws.Config{
|
||||
// Host: "0.0.0.0",
|
||||
// Port: 8080,
|
||||
// GZIP: true,
|
||||
// ReadHeaderTimeout: 5 * time.Second,
|
||||
// WriteTimeout: 15 * time.Second,
|
||||
// IdleTimeout: 120 * time.Second,
|
||||
// }
|
||||
//
|
||||
// # Routing
|
||||
//
|
||||
// HWS uses Go 1.22+ routing patterns with method-specific handlers:
|
||||
//
|
||||
// routes := []hws.Route{
|
||||
// {
|
||||
// Path: "/users/{id}",
|
||||
// Method: hws.MethodGET,
|
||||
// Handler: http.HandlerFunc(getUser),
|
||||
// },
|
||||
// {
|
||||
// Path: "/users/{id}",
|
||||
// Method: hws.MethodPUT,
|
||||
// Handler: http.HandlerFunc(updateUser),
|
||||
// },
|
||||
// }
|
||||
//
|
||||
// Path parameters can be accessed using r.PathValue():
|
||||
//
|
||||
// func getUser(w http.ResponseWriter, r *http.Request) {
|
||||
// id := r.PathValue("id")
|
||||
// // ... handle request
|
||||
// }
|
||||
//
|
||||
// # Middleware
|
||||
//
|
||||
// HWS supports middleware with predictable execution order. Built-in middleware includes
|
||||
// request logging, timing, and GZIP compression:
|
||||
//
|
||||
// server.AddMiddleware()
|
||||
//
|
||||
// Custom middleware can be added using standard http.Handler wrapping:
|
||||
//
|
||||
// server.AddMiddleware(customMiddleware)
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// HWS provides structured error handling with customizable error pages:
|
||||
//
|
||||
// errorPageFunc := func(w http.ResponseWriter, r *http.Request, status int) {
|
||||
// w.WriteHeader(status)
|
||||
// fmt.Fprintf(w, "Error: %d", status)
|
||||
// }
|
||||
//
|
||||
// server.AddErrorPage(errorPageFunc)
|
||||
//
|
||||
// # Logging
|
||||
//
|
||||
// HWS integrates with hlog for structured logging:
|
||||
//
|
||||
// logger, _ := hlog.NewLogger(loggerCfg, os.Stdout)
|
||||
// server.AddLogger(logger)
|
||||
//
|
||||
// The server will automatically log requests, errors, and server lifecycle events.
|
||||
//
|
||||
// # Static Files
|
||||
//
|
||||
// HWS provides safe static file serving that prevents directory listing:
|
||||
//
|
||||
// server.AddStaticFiles("/static", "./public")
|
||||
//
|
||||
// # Graceful Shutdown
|
||||
//
|
||||
// HWS supports graceful shutdown via context cancellation:
|
||||
//
|
||||
// ctx, cancel := context.WithCancel(context.Background())
|
||||
// defer cancel()
|
||||
//
|
||||
// server.Start(ctx)
|
||||
//
|
||||
// // Wait for shutdown signal
|
||||
// sigChan := make(chan os.Signal, 1)
|
||||
// signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
|
||||
// <-sigChan
|
||||
//
|
||||
// // Cancel context to trigger graceful shutdown
|
||||
// cancel()
|
||||
//
|
||||
// # Integration
|
||||
//
|
||||
// HWS integrates with:
|
||||
// - git.haelnorr.com/h/golib/hlog: For structured logging with zerolog
|
||||
// - git.haelnorr.com/h/golib/hwsauth: For JWT-based authentication
|
||||
// - git.haelnorr.com/h/golib/jwt: For JWT token management
|
||||
package hws
|
||||
@@ -1,19 +1,19 @@
|
||||
# HWSAuth
|
||||
# HWSAuth - v0.3.3
|
||||
|
||||
[](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 web framework.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 JWT-based authentication with access and refresh tokens
|
||||
- 🔄 Automatic token rotation and refresh
|
||||
- 🎯 Generic over user model and transaction types
|
||||
- 💾 ORM-agnostic transaction handling (works with GORM, Bun, sqlx, etc.)
|
||||
- ⚙️ Environment variable configuration
|
||||
- 🛡️ Middleware for protecting routes
|
||||
- 🔒 SSL cookie security support
|
||||
- 📦 Type-safe with Go generics
|
||||
- JWT-based authentication with access and refresh tokens
|
||||
- Automatic token rotation and refresh
|
||||
- Generic over user model and transaction types
|
||||
- ORM-agnostic transaction handling (works with GORM, Bun, sqlx, database/sql)
|
||||
- Environment variable configuration with ConfigFromEnv
|
||||
- Middleware for protecting routes
|
||||
- SSL cookie security support
|
||||
- Type-safe with Go generics
|
||||
- Path ignoring for public routes
|
||||
- Automatic re-authentication handling
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -29,9 +29,10 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"git.haelnorr.com/h/golib/hwsauth"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"github.com/rs/zerolog"
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
@@ -69,6 +70,15 @@ func main() {
|
||||
serverCfg, _ := hws.ConfigFromEnv()
|
||||
server, _ := hws.NewServer(serverCfg)
|
||||
|
||||
// Create logger
|
||||
logger, _ := hlog.NewLogger(loggerCfg, os.Stdout)
|
||||
|
||||
// Create error page function
|
||||
errorPageFunc := func(w http.ResponseWriter, r *http.Request, status int) {
|
||||
w.WriteHeader(status)
|
||||
fmt.Fprintf(w, "Error: %d", status)
|
||||
}
|
||||
|
||||
// Create authenticator
|
||||
auth, _ := hwsauth.NewAuthenticator[User, *sql.Tx](
|
||||
cfg,
|
||||
@@ -93,7 +103,7 @@ func main() {
|
||||
// Add authentication middleware
|
||||
server.AddMiddleware(auth.Authenticate())
|
||||
|
||||
// Optionally ignore public paths
|
||||
// Ignore public paths
|
||||
auth.IgnorePaths("/", "/login", "/register", "/static")
|
||||
|
||||
// Start server
|
||||
@@ -106,18 +116,9 @@ func main() {
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/hwsauth).
|
||||
For detailed documentation, see the [HWSAuth Wiki](https://git.haelnorr.com/h/golib/wiki/HWSAuth.md).
|
||||
|
||||
### Key Topics
|
||||
|
||||
- [Configuration](https://git.haelnorr.com/h/golib/wiki/hwsauth#configuration)
|
||||
- [User Model](https://git.haelnorr.com/h/golib/wiki/hwsauth#user-model)
|
||||
- [Authentication Flow](https://git.haelnorr.com/h/golib/wiki/hwsauth#authentication-flow)
|
||||
- [Login & Logout](https://git.haelnorr.com/h/golib/wiki/hwsauth#login-logout)
|
||||
- [Route Protection](https://git.haelnorr.com/h/golib/wiki/hwsauth#route-protection)
|
||||
- [Token Refresh](https://git.haelnorr.com/h/golib/wiki/hwsauth#token-refresh)
|
||||
- [Using with ORMs](https://git.haelnorr.com/h/golib/wiki/hwsauth#using-with-orms)
|
||||
- [Security Best Practices](https://git.haelnorr.com/h/golib/wiki/hwsauth#security-best-practices)
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/hwsauth).
|
||||
|
||||
## Supported ORMs
|
||||
|
||||
@@ -128,7 +129,7 @@ Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details.
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
@@ -138,4 +139,4 @@ Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
- [hws](https://git.haelnorr.com/h/golib/hws) - The web server framework
|
||||
- [jwt](https://git.haelnorr.com/h/golib/jwt) - JWT token generation and validation
|
||||
|
||||
- [hlog](https://git.haelnorr.com/h/golib/hlog) - Structured logging with zerolog
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package hwsauth
|
||||
|
||||
import (
|
||||
"git.haelnorr.com/h/golib/hlog"
|
||||
"git.haelnorr.com/h/golib/hws"
|
||||
"git.haelnorr.com/h/golib/jwt"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Authenticator[T Model, TX DBTransaction] struct {
|
||||
@@ -12,7 +12,7 @@ type Authenticator[T Model, TX DBTransaction] struct {
|
||||
load LoadFunc[T, TX]
|
||||
beginTx BeginTX
|
||||
ignoredPaths []string
|
||||
logger *zerolog.Logger
|
||||
logger *hlog.Logger
|
||||
server *hws.Server
|
||||
errorPage hws.ErrorPageFunc
|
||||
SSL bool // Use SSL for JWT tokens. Default true
|
||||
@@ -28,7 +28,7 @@ func NewAuthenticator[T Model, TX DBTransaction](
|
||||
load LoadFunc[T, TX],
|
||||
server *hws.Server,
|
||||
beginTx BeginTX,
|
||||
logger *zerolog.Logger,
|
||||
logger *hlog.Logger,
|
||||
errorPage hws.ErrorPageFunc,
|
||||
) (*Authenticator[T, TX], error) {
|
||||
if load == nil {
|
||||
|
||||
@@ -179,18 +179,18 @@
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The following environment variables are supported:
|
||||
// The following environment variables are supported when using ConfigFromEnv:
|
||||
//
|
||||
// - HWSAUTH_SSL: Enable SSL mode (default: false)
|
||||
// - HWSAUTH_TRUSTED_HOST: Trusted host for SSL (required if SSL is true)
|
||||
// - HWSAUTH_SECRET_KEY: Secret key for signing tokens (required)
|
||||
// - HWSAUTH_SSL: Enable SSL secure cookies (default: false)
|
||||
// - HWSAUTH_TRUSTED_HOST: Full server address for SSL (required if SSL is true)
|
||||
// - HWSAUTH_SECRET_KEY: Secret key for signing JWT tokens (required)
|
||||
// - HWSAUTH_ACCESS_TOKEN_EXPIRY: Access token expiry in minutes (default: 5)
|
||||
// - HWSAUTH_REFRESH_TOKEN_EXPIRY: Refresh token expiry in minutes (default: 1440)
|
||||
// - HWSAUTH_TOKEN_FRESH_TIME: Token fresh time in minutes (default: 5)
|
||||
// - HWSAUTH_LANDING_PAGE: Landing page for logged in users (default: "/profile")
|
||||
// - HWSAUTH_JWT_TABLE_NAME: Custom JWT table name (optional)
|
||||
// - HWSAUTH_DATABASE_TYPE: Database type (e.g., "postgres", "mysql")
|
||||
// - HWSAUTH_DATABASE_VERSION: Database version (e.g., "15")
|
||||
// - HWSAUTH_LANDING_PAGE: Redirect destination for authenticated users (default: "/profile")
|
||||
// - HWSAUTH_DATABASE_TYPE: Database type - postgres, mysql, sqlite, mariadb (default: "postgres")
|
||||
// - HWSAUTH_DATABASE_VERSION: Database version string (default: "15")
|
||||
// - HWSAUTH_JWT_TABLE_NAME: Custom JWT blacklist table name (default: "jwtblacklist")
|
||||
//
|
||||
// # Security Considerations
|
||||
//
|
||||
|
||||
@@ -8,11 +8,11 @@ require (
|
||||
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
|
||||
git.haelnorr.com/h/golib/hlog v0.9.1
|
||||
)
|
||||
|
||||
require (
|
||||
git.haelnorr.com/h/golib/hlog v0.9.1 // indirect
|
||||
github.com/rs/zerolog v1.34.0 // 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
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
# JWT Package
|
||||
|
||||
[](https://pkg.go.dev/git.haelnorr.com/h/golib/jwt)
|
||||
# JWT - v0.10.1
|
||||
|
||||
JWT (JSON Web Token) generation and validation with database-backed token revocation support.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 Access and refresh token generation
|
||||
- ✅ Token validation with expiration checking
|
||||
- 🚫 Token revocation via database blacklist
|
||||
- 🗄️ Multi-database support (PostgreSQL, MySQL, SQLite, MariaDB)
|
||||
- 🔧 Compatible with database/sql, GORM, and Bun
|
||||
- 🤖 Automatic table creation and management
|
||||
- 🧹 Database-native automatic cleanup
|
||||
- 🔄 Token freshness tracking
|
||||
- 💾 "Remember me" functionality
|
||||
- Access and refresh token generation
|
||||
- Token validation with expiration checking
|
||||
- Token revocation via database blacklist
|
||||
- Multi-database support (PostgreSQL, MySQL, SQLite, MariaDB)
|
||||
- Compatible with database/sql, GORM, and Bun ORMs
|
||||
- Automatic table creation and management
|
||||
- Database-native automatic cleanup
|
||||
- Token freshness tracking for sensitive operations
|
||||
- "Remember me" functionality with session vs persistent tokens
|
||||
- Manual cleanup method for on-demand token cleanup
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -41,7 +40,7 @@ func main() {
|
||||
|
||||
// Create a transaction getter function
|
||||
txGetter := func(ctx context.Context) (jwt.DBTransaction, error) {
|
||||
return db.Begin()
|
||||
return db.BeginTx(ctx, nil)
|
||||
}
|
||||
|
||||
// Create token generator
|
||||
@@ -78,16 +77,9 @@ func main() {
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/h/golib/wiki/JWT).
|
||||
For detailed documentation, see the [JWT Wiki](https://git.haelnorr.com/h/golib/wiki/JWT.md).
|
||||
|
||||
### Key Topics
|
||||
|
||||
- [Configuration](https://git.haelnorr.com/h/golib/wiki/JWT#configuration)
|
||||
- [Token Generation](https://git.haelnorr.com/h/golib/wiki/JWT#token-generation)
|
||||
- [Token Validation](https://git.haelnorr.com/h/golib/wiki/JWT#token-validation)
|
||||
- [Token Revocation](https://git.haelnorr.com/h/golib/wiki/JWT#token-revocation)
|
||||
- [Cleanup](https://git.haelnorr.com/h/golib/wiki/JWT#cleanup)
|
||||
- [Using with ORMs](https://git.haelnorr.com/h/golib/wiki/JWT#using-with-orms)
|
||||
Additional API documentation is available at [GoDoc](https://pkg.go.dev/git.haelnorr.com/h/golib/jwt).
|
||||
|
||||
## Supported Databases
|
||||
|
||||
@@ -98,8 +90,13 @@ Comprehensive documentation is available in the [Wiki](https://git.haelnorr.com/
|
||||
|
||||
## License
|
||||
|
||||
See LICENSE file in the repository root.
|
||||
This project is licensed under the MIT License - see the LICENSE file for details.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please open an issue or submit a pull request.
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
## Related Projects
|
||||
|
||||
- [hwsauth](https://git.haelnorr.com/h/golib/hwsauth) - JWT-based authentication middleware for HWS
|
||||
- [hws](https://git.haelnorr.com/h/golib/hws) - HTTP web server framework
|
||||
|
||||
26
tmdb/api.go
Normal file
26
tmdb/api.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"git.haelnorr.com/h/golib/env"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
*Config
|
||||
token string // ENV TMDB_TOKEN: API token for TMDB (required)
|
||||
}
|
||||
|
||||
func NewAPIConnection() (*API, error) {
|
||||
token := env.String("TMDB_TOKEN", "")
|
||||
if token == "" {
|
||||
return nil, errors.New("No TMDB API Token provided")
|
||||
}
|
||||
api := &API{
|
||||
token: token,
|
||||
}
|
||||
err := api.getConfig()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "api.getConfig")
|
||||
}
|
||||
return api, nil
|
||||
}
|
||||
@@ -20,13 +20,17 @@ type Image struct {
|
||||
StillSizes []string `json:"still_sizes"`
|
||||
}
|
||||
|
||||
func GetConfig(token string) (*Config, error) {
|
||||
url := "https://api.themoviedb.org/3/configuration"
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) getConfig() error {
|
||||
url := requestURL("configuration")
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return errors.Wrap(err, "api.get")
|
||||
}
|
||||
config := Config{}
|
||||
json.Unmarshal(data, &config)
|
||||
return &config, nil
|
||||
err = json.Unmarshal(data, &config)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "json.Unmarshal")
|
||||
}
|
||||
api.Config = &config
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -42,11 +42,12 @@ type Crew struct {
|
||||
Job string `json:"job"`
|
||||
}
|
||||
|
||||
func GetCredits(movieid int32, token string) (*Credits, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v/credits?language=en-US", movieid)
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) GetCredits(movieid int64) (*Credits, error) {
|
||||
path := []string{"movie", strconv.FormatInt(movieid, 10), "credits"}
|
||||
url := buildURL(path, nil)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
credits := Credits{}
|
||||
json.Unmarshal(data, &credits)
|
||||
|
||||
160
tmdb/doc.go
Normal file
160
tmdb/doc.go
Normal file
@@ -0,0 +1,160 @@
|
||||
// Package tmdb provides a client for The Movie Database (TMDB) API.
|
||||
//
|
||||
// This package offers a clean interface for interacting with TMDB's REST API,
|
||||
// including automatic rate limiting, retry logic, and convenient URL building utilities.
|
||||
//
|
||||
// # Getting Started
|
||||
//
|
||||
// First, create an API connection using your TMDB API token:
|
||||
//
|
||||
// api, err := tmdb.NewAPIConnection()
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// The token is read from the TMDB_TOKEN environment variable.
|
||||
//
|
||||
// # Making Requests
|
||||
//
|
||||
// The package provides clean URL building functions to construct API requests:
|
||||
//
|
||||
// // Simple endpoint
|
||||
// url := tmdb.requestURL("movie", "550")
|
||||
// // Result: "https://api.themoviedb.org/3/movie/550"
|
||||
//
|
||||
// // With query parameters
|
||||
// url := tmdb.buildURL([]string{"search", "movie"}, map[string]string{
|
||||
// "query": "Inception",
|
||||
// "page": "1",
|
||||
// })
|
||||
// // Result: "https://api.themoviedb.org/3/search/movie?language=en-US&page=1&query=Inception"
|
||||
//
|
||||
// All requests made with buildURL automatically include "language=en-US" by default.
|
||||
//
|
||||
// # Rate Limiting
|
||||
//
|
||||
// TMDB has rate limits around 40 requests per second. This package implements
|
||||
// automatic retry logic with exponential backoff:
|
||||
//
|
||||
// - Initial backoff: 1 second
|
||||
// - Exponential growth: 1s → 2s → 4s → 8s → 16s → 32s (max)
|
||||
// - Maximum retries: 3 attempts
|
||||
// - Respects Retry-After header when provided by the API
|
||||
//
|
||||
// Example of rate-limited request:
|
||||
//
|
||||
// data, err := api.get(url)
|
||||
// if err != nil {
|
||||
// // Will return error only after exhausting all retries
|
||||
// log.Printf("Request failed: %v", err)
|
||||
// }
|
||||
//
|
||||
// # Searching for Movies
|
||||
//
|
||||
// Search for movies by title:
|
||||
//
|
||||
// results, err := tmdb.SearchMovies(token, "Fight Club", false, 1)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// for _, movie := range results.Results {
|
||||
// fmt.Printf("%s %s\n", movie.Title, movie.ReleaseYear())
|
||||
// fmt.Printf("Poster: %s\n", movie.GetPoster(&api.Image, "w500"))
|
||||
// }
|
||||
//
|
||||
// # Getting Movie Details
|
||||
//
|
||||
// Fetch detailed information about a specific movie:
|
||||
//
|
||||
// movie, err := tmdb.GetMovie(550, token)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Printf("Title: %s\n", movie.Title)
|
||||
// fmt.Printf("Overview: %s\n", movie.Overview)
|
||||
// fmt.Printf("Release Date: %s\n", movie.ReleaseDate)
|
||||
// fmt.Printf("IMDb ID: %s\n", movie.IMDbID)
|
||||
//
|
||||
// # Getting Credits
|
||||
//
|
||||
// Retrieve cast and crew information:
|
||||
//
|
||||
// credits, err := tmdb.GetCredits(550, token)
|
||||
// if err != nil {
|
||||
// log.Fatal(err)
|
||||
// }
|
||||
//
|
||||
// fmt.Println("Cast:")
|
||||
// for _, actor := range credits.Cast {
|
||||
// fmt.Printf(" %s as %s\n", actor.Name, actor.Character)
|
||||
// }
|
||||
//
|
||||
// fmt.Println("\nCrew:")
|
||||
// for _, member := range credits.Crew {
|
||||
// if member.Job == "Director" {
|
||||
// fmt.Printf(" Director: %s\n", member.Name)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// # Image URLs
|
||||
//
|
||||
// The API configuration includes base URLs for images. Use helper methods to
|
||||
// construct full image URLs:
|
||||
//
|
||||
// posterURL := movie.GetPoster(&api.Image, "w500")
|
||||
// // Available sizes: "w92", "w154", "w185", "w342", "w500", "w780", "original"
|
||||
//
|
||||
// # Error Handling
|
||||
//
|
||||
// The package returns wrapped errors for easy debugging:
|
||||
//
|
||||
// data, err := api.get(url)
|
||||
// if err != nil {
|
||||
// if strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
// // Handle rate limiting
|
||||
// } else if strings.Contains(err.Error(), "unexpected status code") {
|
||||
// // Handle HTTP errors
|
||||
// } else {
|
||||
// // Handle network errors
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Common error scenarios:
|
||||
// - "rate limit exceeded: maximum retries reached" - All retry attempts exhausted
|
||||
// - "unexpected status code: 401" - Invalid API token
|
||||
// - "unexpected status code: 404" - Resource not found
|
||||
// - Network errors for connectivity issues
|
||||
//
|
||||
// # Environment Variables
|
||||
//
|
||||
// The package uses the following environment variable:
|
||||
//
|
||||
// - TMDB_TOKEN: Your TMDB API access token (required)
|
||||
//
|
||||
// Obtain an API token from: https://www.themoviedb.org/settings/api
|
||||
//
|
||||
// # Best Practices
|
||||
//
|
||||
// 1. Reuse the API connection instead of creating new ones for each request
|
||||
// 2. Use buildURL for consistency and automatic language parameter injection
|
||||
// 3. Handle rate limit errors gracefully - they indicate temporary service issues
|
||||
// 4. Cache API responses when appropriate to reduce API calls
|
||||
// 5. Use specific image sizes instead of "original" to save bandwidth
|
||||
//
|
||||
// # API Documentation
|
||||
//
|
||||
// For complete TMDB API documentation, visit:
|
||||
// https://developer.themoviedb.org/docs
|
||||
//
|
||||
// # Rate Limiting Details
|
||||
//
|
||||
// From TMDB's documentation:
|
||||
// "While our legacy rate limits have been disabled for some time, we do still
|
||||
// have some upper limits to help mitigate needlessly high bulk scraping. They
|
||||
// sit somewhere in the 40 requests per second range."
|
||||
//
|
||||
// This package automatically handles rate limiting with exponential backoff to
|
||||
// ensure respectful API usage.
|
||||
package tmdb
|
||||
@@ -2,4 +2,7 @@ module git.haelnorr.com/h/golib/tmdb
|
||||
|
||||
go 1.25.5
|
||||
|
||||
require github.com/pkg/errors v0.9.1
|
||||
require (
|
||||
git.haelnorr.com/h/golib/env v0.9.1
|
||||
github.com/pkg/errors v0.9.1
|
||||
)
|
||||
|
||||
@@ -1,2 +1,4 @@
|
||||
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/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
|
||||
@@ -2,7 +2,7 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -33,11 +33,12 @@ type Movie struct {
|
||||
Video bool `json:"video"`
|
||||
}
|
||||
|
||||
func GetMovie(id int32, token string) (*Movie, error) {
|
||||
url := fmt.Sprintf("https://api.themoviedb.org/3/movie/%v?language=en-US", id)
|
||||
data, err := tmdbGet(url, token)
|
||||
func (api *API) GetMovie(movieid int64) (*Movie, error) {
|
||||
path := []string{"movie", strconv.FormatInt(movieid, 10)}
|
||||
url := buildURL(path, nil)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
movie := Movie{}
|
||||
json.Unmarshal(data, &movie)
|
||||
|
||||
122
tmdb/request.go
122
tmdb/request.go
@@ -4,25 +4,113 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func tmdbGet(url string, token string) ([]byte, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.NewRequest")
|
||||
}
|
||||
req.Header.Add("accept", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.DefaultClient.Do")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "io.ReadAll")
|
||||
}
|
||||
return body, nil
|
||||
const baseURL string = "https://api.themoviedb.org"
|
||||
const apiVer string = "3"
|
||||
|
||||
const (
|
||||
maxRetries = 3 // Maximum number of retry attempts for 429 responses
|
||||
initialBackoff = 1 * time.Second // Initial backoff duration
|
||||
maxBackoff = 32 * time.Second // Maximum backoff duration
|
||||
)
|
||||
|
||||
// requestURL builds a clean API URL from path segments.
|
||||
// Example: requestURL("movie", "550") -> "https://api.themoviedb.org/3/movie/550"
|
||||
// Example: requestURL("search", "movie") -> "https://api.themoviedb.org/3/search/movie"
|
||||
func requestURL(pathSegments ...string) string {
|
||||
path := strings.Join(pathSegments, "/")
|
||||
return fmt.Sprintf("%s/%s/%s", baseURL, apiVer, path)
|
||||
}
|
||||
|
||||
// buildURL is a convenience function that builds a URL with query parameters.
|
||||
// Example: buildURL([]string{"search", "movie"}, map[string]string{"query": "Inception", "page": "1"})
|
||||
func buildURL(pathSegments []string, params map[string]string) string {
|
||||
baseURL := requestURL(pathSegments...)
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
params["language"] = "en-US"
|
||||
values := url.Values{}
|
||||
for key, val := range params {
|
||||
values.Add(key, val)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s?%s", baseURL, values.Encode())
|
||||
}
|
||||
|
||||
// get performs a GET request to the TMDB API with proper authentication headers
|
||||
// and automatic retry logic with exponential backoff for rate limiting (429 responses).
|
||||
//
|
||||
// The TMDB API has rate limits around 40 requests per second. This function
|
||||
// implements a courtesy backoff mechanism that:
|
||||
// - Retries up to maxRetries times on 429 responses
|
||||
// - Uses exponential backoff: 1s, 2s, 4s, 8s, etc. (up to maxBackoff)
|
||||
// - Returns an error if max retries are exceeded
|
||||
//
|
||||
// The url parameter should be the full URL (can be built using requestURL or buildURL).
|
||||
func (api *API) get(url string) ([]byte, error) {
|
||||
backoff := initialBackoff
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.NewRequest")
|
||||
}
|
||||
req.Header.Add("accept", "application/json")
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", api.token))
|
||||
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "http.DefaultClient.Do")
|
||||
}
|
||||
|
||||
// Check for rate limiting (429 Too Many Requests)
|
||||
if res.StatusCode == http.StatusTooManyRequests {
|
||||
res.Body.Close()
|
||||
|
||||
// If we've exhausted retries, return an error
|
||||
if attempt >= maxRetries {
|
||||
return nil, errors.New("rate limit exceeded: maximum retries reached")
|
||||
}
|
||||
|
||||
// Check for Retry-After header first (respect server's guidance)
|
||||
if retryAfter := res.Header.Get("Retry-After"); retryAfter != "" {
|
||||
if duration, err := time.ParseDuration(retryAfter + "s"); err == nil {
|
||||
backoff = duration
|
||||
}
|
||||
}
|
||||
|
||||
// Apply exponential backoff: 1s, 2s, 4s, 8s, etc.
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
|
||||
time.Sleep(backoff)
|
||||
|
||||
// Double the backoff for next iteration
|
||||
backoff *= 2
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
// For other error status codes, return an error
|
||||
if res.StatusCode != http.StatusOK {
|
||||
return nil, errors.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
// Success - read and return body
|
||||
body, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "io.ReadAll")
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
return nil, errors.Errorf("max retries (%d) exceeded due to rate limiting (HTTP 429)", maxRetries)
|
||||
}
|
||||
|
||||
360
tmdb/request_test.go
Normal file
360
tmdb/request_test.go
Normal file
@@ -0,0 +1,360 @@
|
||||
package tmdb
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
segments []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single segment",
|
||||
segments: []string{"configuration"},
|
||||
want: "https://api.themoviedb.org/3/configuration",
|
||||
},
|
||||
{
|
||||
name: "two segments",
|
||||
segments: []string{"search", "movie"},
|
||||
want: "https://api.themoviedb.org/3/search/movie",
|
||||
},
|
||||
{
|
||||
name: "movie with id",
|
||||
segments: []string{"movie", "550"},
|
||||
want: "https://api.themoviedb.org/3/movie/550",
|
||||
},
|
||||
{
|
||||
name: "movie with id and credits",
|
||||
segments: []string{"movie", "550", "credits"},
|
||||
want: "https://api.themoviedb.org/3/movie/550/credits",
|
||||
},
|
||||
{
|
||||
name: "no segments",
|
||||
segments: []string{},
|
||||
want: "https://api.themoviedb.org/3/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := requestURL(tt.segments...)
|
||||
if got != tt.want {
|
||||
t.Errorf("requestURL() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
segments []string
|
||||
params map[string]string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "no params",
|
||||
segments: []string{"movie", "550"},
|
||||
params: nil,
|
||||
want: "https://api.themoviedb.org/3/movie/550?language=en-US",
|
||||
},
|
||||
{
|
||||
name: "with query param",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "Inception",
|
||||
},
|
||||
want: "https://api.themoviedb.org/3/search/movie?language=en-US&query=Inception",
|
||||
},
|
||||
{
|
||||
name: "multiple params",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "Fight Club",
|
||||
"page": "2",
|
||||
"include_adult": "false",
|
||||
},
|
||||
// Note: URL params can be in any order, so we check contains instead
|
||||
want: "https://api.themoviedb.org/3/search/movie?",
|
||||
},
|
||||
{
|
||||
name: "params with special characters",
|
||||
segments: []string{"search", "movie"},
|
||||
params: map[string]string{
|
||||
"query": "The Matrix",
|
||||
},
|
||||
want: "https://api.themoviedb.org/3/search/movie?",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := buildURL(tt.segments, tt.params)
|
||||
if !strings.HasPrefix(got, tt.want) {
|
||||
t.Errorf("buildURL() = %v, want prefix %v", got, tt.want)
|
||||
}
|
||||
// Check that all params are present (checking keys, values may be URL encoded)
|
||||
for key := range tt.params {
|
||||
if !strings.Contains(got, key+"=") {
|
||||
t.Errorf("buildURL() missing param key %s in %v", key, got)
|
||||
}
|
||||
}
|
||||
// Check that language is always added
|
||||
if !strings.Contains(got, "language=en-US") {
|
||||
t.Errorf("buildURL() missing default language param in %v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_Success(t *testing.T) {
|
||||
// Create a test server that returns 200 OK
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify headers
|
||||
if r.Header.Get("accept") != "application/json" {
|
||||
t.Errorf("missing or incorrect accept header")
|
||||
}
|
||||
if !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
||||
t.Errorf("missing or incorrect Authorization header")
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
body, err := api.get(server.URL)
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RateLimitRetry(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that returns 429 twice, then 200
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
if attemptCount <= 2 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
start := time.Now()
|
||||
body, err := api.get(server.URL)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attemptCount != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attemptCount)
|
||||
}
|
||||
|
||||
// Should have waited at least 1s + 2s = 3s total
|
||||
if elapsed < 3*time.Second {
|
||||
t.Errorf("expected backoff delay, got %v", elapsed)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RateLimitExceeded(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that always returns 429
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "rate limit exceeded") {
|
||||
t.Errorf("get() expected rate limit error, got: %v", err)
|
||||
}
|
||||
|
||||
// Should have attempted maxRetries + 1 times (initial + retries)
|
||||
expectedAttempts := maxRetries + 1
|
||||
if attemptCount != expectedAttempts {
|
||||
t.Errorf("expected %d attempts, got %d", expectedAttempts, attemptCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_RetryAfterHeader(t *testing.T) {
|
||||
attemptCount := 0
|
||||
|
||||
// Create a test server that returns 429 with Retry-After header
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
attemptCount++
|
||||
if attemptCount == 1 {
|
||||
w.Header().Set("Retry-After", "2")
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
start := time.Now()
|
||||
body, err := api.get(server.URL)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("get() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Should have waited at least 2s as specified in Retry-After
|
||||
if elapsed < 2*time.Second {
|
||||
t.Errorf("expected at least 2s delay from Retry-After header, got %v", elapsed)
|
||||
}
|
||||
|
||||
expected := `{"success": true}`
|
||||
if string(body) != expected {
|
||||
t.Errorf("get() = %v, want %v", string(body), expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_NonOKStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
statusCode int
|
||||
}{
|
||||
{"bad request", http.StatusBadRequest},
|
||||
{"unauthorized", http.StatusUnauthorized},
|
||||
{"forbidden", http.StatusForbidden},
|
||||
{"not found", http.StatusNotFound},
|
||||
{"internal server error", http.StatusInternalServerError},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(tt.statusCode)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error, got nil")
|
||||
}
|
||||
|
||||
expectedError := fmt.Sprintf("unexpected status code: %d", tt.statusCode)
|
||||
if !strings.Contains(err.Error(), expectedError) {
|
||||
t.Errorf("get() expected error containing %q, got: %v", expectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_NetworkError(t *testing.T) {
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get("http://invalid-domain-that-does-not-exist.local")
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error for invalid domain, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "http.DefaultClient.Do") {
|
||||
t.Errorf("get() expected network error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_InvalidURL(t *testing.T) {
|
||||
api := &API{token: "test-token"}
|
||||
_, err := api.get("://invalid-url")
|
||||
|
||||
if err == nil {
|
||||
t.Error("get() expected error for invalid URL, got nil")
|
||||
}
|
||||
|
||||
if !strings.Contains(err.Error(), "http.NewRequest") {
|
||||
t.Errorf("get() expected URL parse error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIGet_ReadBodyError(t *testing.T) {
|
||||
// Create a test server that closes connection before body is read
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Length", "100")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
// Don't write anything, causing a read error
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
|
||||
// Note: This test may not always fail as expected due to how httptest works
|
||||
// In real scenarios, network issues would cause io.ReadAll to fail
|
||||
_, err := api.get(server.URL)
|
||||
|
||||
// Just verify we got a response (this test is mainly for coverage)
|
||||
if err != nil && !strings.Contains(err.Error(), "io.ReadAll") {
|
||||
t.Logf("get() error (expected in some cases): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Benchmark tests
|
||||
func BenchmarkRequestURL(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
requestURL("movie", "550", "credits")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkBuildURL(b *testing.B) {
|
||||
params := map[string]string{
|
||||
"query": "Inception",
|
||||
"page": "1",
|
||||
}
|
||||
for i := 0; i < b.N; i++ {
|
||||
buildURL([]string{"search", "movie"}, params)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAPIGet(b *testing.B) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
io.WriteString(w, `{"success": true}`)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
api := &API{token: "test-token"}
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
api.get(server.URL)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,9 @@ package tmdb
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
@@ -63,17 +63,19 @@ func (movie *ResultMovie) ReleaseYear() string {
|
||||
// return genres[:len(genres)-2]
|
||||
// }
|
||||
|
||||
func SearchMovies(token string, query string, adult bool, page int) (*ResultMovies, error) {
|
||||
url := "https://api.themoviedb.org/3/search/movie" +
|
||||
fmt.Sprintf("?query=%s", url.QueryEscape(query)) +
|
||||
fmt.Sprintf("&include_adult=%t", adult) +
|
||||
fmt.Sprintf("&page=%v", page) +
|
||||
"&language=en-US"
|
||||
response, err := tmdbGet(url, token)
|
||||
func (api *API) SearchMovies(query string, adult bool, page int64) (*ResultMovies, error) {
|
||||
path := []string{"searc", "movie"}
|
||||
params := map[string]string{
|
||||
"query": url.QueryEscape(query),
|
||||
"include_adult": strconv.FormatBool(adult),
|
||||
"page": strconv.FormatInt(page, 10),
|
||||
}
|
||||
url := buildURL(path, params)
|
||||
data, err := api.get(url)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "tmdbGet")
|
||||
return nil, errors.Wrap(err, "api.get")
|
||||
}
|
||||
var results ResultMovies
|
||||
json.Unmarshal(response, &results)
|
||||
json.Unmarshal(data, &results)
|
||||
return &results, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user