feat: add logging and support for custom logger (#36)

* feat: add support for logging

* fix linting

* chore: remove old APIError in favor of ServerError
This commit is contained in:
Mateusz Filipowicz
2025-02-23 12:59:46 +01:00
committed by GitHub
parent 95a4ff87ea
commit e79dcb13f0
13 changed files with 260 additions and 63 deletions

View File

@@ -6,6 +6,7 @@ linters:
- 'mnd'
- 'nlreturn'
- 'tagliatelle'
- 'ireturn'
# Temporary
- 'cyclop'

View File

@@ -10,6 +10,8 @@ import (
type {{ .Name }} interface {
Logger
{{- range $k, $v := .Functions }}
{{ if $v.Comment }}// {{ $v.Comment }}{{ end }}

View File

@@ -40,7 +40,7 @@ type defaultUnifiVersionProvider struct {
firmwareUpdateApi string
}
func NewUnifiVersionProvider(firmwareUpdateApi string) UnifiVersionProvider { //nolint:ireturn
func NewUnifiVersionProvider(firmwareUpdateApi string) UnifiVersionProvider {
return &defaultUnifiVersionProvider{
firmwareUpdateApi: firmwareUpdateApi,
}

View File

@@ -94,8 +94,76 @@ if err != nil {
## Debugging and Logging
For troubleshooting, it might be useful to enable verbose logging. You can implement an interceptor to log additional
details like headers, body content, and timings. This can be enabled conditionally in your application's debug mode.
The SDK provides flexible logging capabilities through the `Logger` interface. You can either use the default logger or implement your own custom logger.
### Using the Default Logger
The SDK includes a default logger based on [logrus](https://github.com/sirupsen/logrus). You can configure it with different logging levels:
```go
// Configure client with default logger at Debug level
config := &unifi.ClientConfig{
URL: "https://unifi.localdomain",
APIKey: "your-api-key",
Logger: unifi.NewDefaultLogger(unifi.DebugLevel),
}
client, err := unifi.NewClient(config)
```
Available logging levels are:
- `unifi.DisabledLevel` - no logging
- `unifi.TraceLevel` - most verbose level
- `unifi.DebugLevel` - debug information
- `unifi.InfoLevel` - default level, informational messages
- `unifi.WarnLevel` - warning messages
- `unifi.ErrorLevel` - error messages only
Then `Logger` methods are available to be used within the client:
```go
client.Logger.Trace("Trace message")
client.Logger.Tracef("Trace message with %s", "formatting")
client.Logger.Debug("Debug message")
client.Logger.Debugf("Debug message with %s", "formatting")
client.Logger.Info("Info message")
client.Logger.Infof("Info message with %s", "formatting")
client.Logger.Warn("Warn message")
client.Logger.Warnf("Warn message with %s", "formatting")
client.Logger.Error("Error message")
client.Logger.Errorf("Error message with %s", "formatting")
```
### Custom Logger Implementation
You can implement your own logger by implementing the `Logger` interface:
```go
type MyCustomLogger struct {
// your logger fields
}
// Implement all required methods
func (l *MyCustomLogger) Trace(msg string) { /* implementation */ }
func (l *MyCustomLogger) Debug(msg string) { /* implementation */ }
func (l *MyCustomLogger) Info(msg string) { /* implementation */ }
func (l *MyCustomLogger) Error(msg string) { /* implementation */ }
func (l *MyCustomLogger) Warn(msg string) { /* implementation */ }
func (l *MyCustomLogger) Tracef(format string, args ...interface{}) { /* implementation */ }
func (l *MyCustomLogger) Debugf(format string, args ...interface{}) { /* implementation */ }
func (l *MyCustomLogger) Infof(format string, args ...interface{}) { /* implementation */ }
func (l *MyCustomLogger) Errorf(format string, args ...interface{}) { /* implementation */ }
func (l *MyCustomLogger) Warnf(format string, args ...interface{}) { /* implementation */ }
// Use custom logger in client configuration
config := &unifi.ClientConfig{
URL: "https://unifi.localdomain",
APIKey: "your-api-key",
Logger: &MyCustomLogger{},
}
client, err := unifi.NewClient(config)
```
If no logger is specified in the configuration, the SDK will use the default logger with `Info` level.
## Advanced Error Handling

View File

@@ -71,6 +71,10 @@ if err != nil {
- `HttpTransportCustomizer` for transport-level customization
- `HttpRoundTripperProvider` for complete HTTP client control
4. **Removed unifi.APIError**:
- Old: `unifi.APIError` struct for API errors
- New: Standard `unifi.ServerError` struct for API errors
5. **Additional Features in filipowm/go-unifi**:
- Validation modes (Soft, Hard, Disabled)
- Request/Response interceptors
@@ -90,6 +94,7 @@ if err != nil {
})
```
4. Remove explicit `Login()` calls as they are now handled automatically, unless you use [bare client initialization](./getting_started.md#BareClientInitialization)
5. Replace usage of `unifi.APIError` with `unifi.ServerError`
The rest of your code using the client methods should continue to work as before, as the API methods remain the same.

View File

@@ -59,6 +59,7 @@ var (
// determineApiStyle checks the base URL to decide which API style to use and sets the apiPaths accordingly.
func (c *client) determineApiStyle() error {
c.Debug("Determining API style")
ctx, cancel := c.newRequestContext()
defer cancel()
@@ -85,8 +86,10 @@ func (c *client) determineApiStyle() error {
switch resp.StatusCode {
case http.StatusOK:
c.Debug("Using new style API")
c.apiPaths = &NewStyleAPI
case http.StatusFound:
c.Debug("Using old style API")
c.apiPaths = &OldStyleAPI
default:
return fmt.Errorf("expected 200 or 302 status code, but got: %d", resp.StatusCode)

View File

@@ -8,6 +8,7 @@ import (
)
type Client interface {
Logger
// BaseURL returns the base URL of the controller.
BaseURL() string

View File

@@ -14,20 +14,17 @@ import (
"golang.org/x/net/publicsuffix"
)
// validationMode represents the mode for request validation.
// ValidationMode represents the mode for request validation.
// It may be set to "soft", "hard", or "disable". The default is "soft".
type validationMode string
type ValidationMode int
const (
// SoftValidation indicates that validation errors are logged as warnings but do not prevent the request from proceeding.
SoftValidation validationMode = "soft"
SoftValidation ValidationMode = iota
// HardValidation indicates that validation errors are treated as fatal and will cause the request to be rejected.
HardValidation validationMode = "hard"
HardValidation
// DisableValidation indicates that no validation is performed on the request body.
DisableValidation validationMode = "disable"
// DefaultValidation is the default validation mode used if none is specified.
// Currently set to SoftValidation, but may change to HardValidation in a future major version.
DefaultValidation validationMode = SoftValidation // TODO: change to hard in next major version
DisableValidation
)
// HttpTransportCustomizer is a function type for customizing the HTTP transport.
@@ -73,7 +70,8 @@ type ClientConfig struct {
UserAgent string
ErrorHandler ResponseErrorHandler
UseLocking bool
ValidationMode validationMode `validate:"omitempty,oneof=soft hard disable"`
ValidationMode ValidationMode
Logger Logger
}
// Credentials abstracts authentication credentials.
@@ -112,12 +110,13 @@ func (u UserPassCredentials) GetPass() string { return u.Password }
// client represents a UniFi client.
type client struct {
Logger
baseURL *url.URL
sysInfo *SysInfo
apiPaths *APIPaths
timeout time.Duration
credentials Credentials
validationMode validationMode
validationMode ValidationMode
useLocking bool
http *http.Client
@@ -168,10 +167,19 @@ func (c *client) Version() string {
}
func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
var log Logger
if config.Logger != nil {
log = config.Logger
} else {
log = NewDefaultLogger(InfoLevel)
}
log.Info("Initializing new UniFi client")
var rt http.RoundTripper
var err error
config.URL = strings.TrimRight(config.URL, "/")
log.Debugf("Connecting to UniFi controller at %s", config.URL)
if config.HttpRoundTripperProvider != nil {
log.Debug("Using custom HTTP round tripper provider")
rt = config.HttpRoundTripperProvider()
}
if rt == nil {
@@ -180,6 +188,7 @@ func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
TLSClientConfig: &tls.Config{InsecureSkipVerify: !config.VerifySSL},
}
if config.HttpTransportCustomizer != nil {
log.Debug("Customizing HTTP transport")
if transport, err = config.HttpTransportCustomizer(transport); err != nil {
return nil, fmt.Errorf("failed customizing HTTP transport: %w", err)
}
@@ -205,14 +214,18 @@ func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
var credentials Credentials
if config.APIKey != "" {
log.Debug("Using API key authentication")
credentials = APIKeyCredentials{APIKey: config.APIKey}
interceptors = append(interceptors, &APIKeyAuthInterceptor{apiKey: config.APIKey})
} else {
log.Debug("Using user/pass authentication")
credentials = UserPassCredentials{User: config.User, Password: config.Password}
interceptors = append(interceptors, &CSRFInterceptor{})
}
if len(config.UserAgent) == 0 {
config.UserAgent = defaultUserAgent
} else {
log.Debugf("Using custom User-Agent header: %s", config.UserAgent)
}
interceptors = append(interceptors, &DefaultHeadersInterceptor{headers: map[string]string{
UserAgentHeader: config.UserAgent,
@@ -221,13 +234,13 @@ func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
}})
var errorHandler ResponseErrorHandler
if config.ErrorHandler != nil {
log.Debug("Using custom response error handler")
errorHandler = config.ErrorHandler
} else {
log.Debug("Using default response error handler")
errorHandler = &DefaultResponseErrorHandler{}
}
if config.ValidationMode == "" {
config.ValidationMode = DefaultValidation
}
log.Tracef("Validation mode: %d", config.ValidationMode)
u := &client{
baseURL: baseURL,
timeout: config.Timeout,
@@ -239,6 +252,7 @@ func newClientFromConfig(config *ClientConfig, v *validator) (*client, error) {
errorHandler: errorHandler,
lock: sync.Mutex{},
validator: v,
Logger: log,
}
for _, interceptor := range config.Interceptors {
u.AddInterceptor(&interceptor)
@@ -262,6 +276,7 @@ func NewClient(config *ClientConfig) (Client, error) { //nolint: ireturn
return c, fmt.Errorf("failed getting server info: %w", err)
} else {
c.sysInfo = sysInfo
c.Debugf("Connected to UniFi controller\nversion: %s; name: %s; build: %s; hostname: %s", sysInfo.Version, sysInfo.Name, sysInfo.Build, sysInfo.Hostname)
}
return c, nil
}
@@ -296,8 +311,10 @@ func newBareClient(config *ClientConfig) (*client, error) {
// It returns an error if the authentication process fails.
func (c *client) Login() error {
if c.credentials.IsAPIKey() {
c.Trace("API key authentication; skipping login")
return nil
}
c.Trace("Logging in with user/pass credentials")
ctx, cancel := c.newRequestContext()
defer cancel()

115
unifi/logging.go Normal file
View File

@@ -0,0 +1,115 @@
package unifi
import (
"github.com/sirupsen/logrus"
)
type Logger interface {
Trace(format string)
Debug(format string)
Info(format string)
Error(format string)
Warn(format string)
Tracef(format string, args ...interface{})
Debugf(format string, args ...interface{})
Infof(format string, args ...interface{})
Errorf(format string, args ...interface{})
Warnf(format string, args ...interface{})
}
type LoggingLevel int
const (
DisabledLevel LoggingLevel = iota
TraceLevel
DebugLevel
InfoLevel
WarnLevel
ErrorLevel
)
func NewDefaultLogger(level LoggingLevel) Logger {
l := logrus.New()
var logrusLevel logrus.Level
switch level {
case DisabledLevel:
return &noopLogger{}
case TraceLevel:
logrusLevel = logrus.TraceLevel
case DebugLevel:
logrusLevel = logrus.DebugLevel
case InfoLevel:
logrusLevel = logrus.InfoLevel
case WarnLevel:
logrusLevel = logrus.WarnLevel
case ErrorLevel:
logrusLevel = logrus.ErrorLevel
default:
logrusLevel = logrus.InfoLevel
}
l.SetLevel(logrusLevel)
l.SetFormatter(&logrus.TextFormatter{
DisableTimestamp: true,
DisableLevelTruncation: true,
FullTimestamp: false,
ForceColors: true,
})
return &defaultLogger{l}
}
type noopLogger struct{}
func (l *noopLogger) Trace(msg string) {}
func (l *noopLogger) Debug(msg string) {}
func (l *noopLogger) Info(msg string) {}
func (l *noopLogger) Error(msg string) {}
func (l *noopLogger) Warn(msg string) {}
func (l *noopLogger) Tracef(format string, args ...interface{}) {}
func (l *noopLogger) Debugf(format string, args ...interface{}) {}
func (l *noopLogger) Infof(format string, args ...interface{}) {}
func (l *noopLogger) Errorf(format string, args ...interface{}) {}
func (l *noopLogger) Warnf(format string, args ...interface{}) {}
type defaultLogger struct {
*logrus.Logger
}
func (l *defaultLogger) Trace(msg string) {
l.Logger.Trace(msg)
}
func (l *defaultLogger) Debug(msg string) {
l.Logger.Debug(msg)
}
func (l *defaultLogger) Info(msg string) {
l.Logger.Info(msg)
}
func (l *defaultLogger) Error(msg string) {
l.Logger.Error(msg)
}
func (l *defaultLogger) Warn(msg string) {
l.Logger.Warn(msg)
}
func (l *defaultLogger) Tracef(format string, args ...interface{}) {
l.Logger.Tracef(format, args...)
}
func (l *defaultLogger) Debugf(format string, args ...interface{}) {
l.Logger.Debugf(format, args...)
}
func (l *defaultLogger) Infof(format string, args ...interface{}) {
l.Logger.Infof(format, args...)
}
func (l *defaultLogger) Errorf(format string, args ...interface{}) {
l.Logger.Errorf(format, args...)
}
func (l *defaultLogger) Warnf(format string, args ...interface{}) {
l.Logger.Warnf(format, args...)
}

View File

@@ -39,12 +39,12 @@ func (c *client) buildRequestURL(apiPath string) (*url.URL, error) {
// validateRequestBody validates the request body if validation is enabled.
func (c *client) validateRequestBody(reqBody interface{}) error {
if reqBody != nil && c.validationMode != DisableValidation {
c.Trace("Validating request body")
if err := c.validator.Validate(reqBody); err != nil {
err = fmt.Errorf("failed validating request body: %w", err)
if c.validationMode == HardValidation {
return err
return fmt.Errorf("failed validating request body: %w", err)
} else {
fmt.Println(err)
c.Warnf("failed validating request body: %s", err)
}
}
}
@@ -64,6 +64,7 @@ func (c *client) newRequestContext() (context.Context, context.CancelFunc) {
// It validates the request body, applies interceptors, and decodes the HTTP response into respBody if provided.
// It returns an error if the request or response handling fails.
func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interface{}, respBody interface{}) error {
c.Tracef("Performing request: %s %s", method, apiPath)
if err := c.validateRequestBody(reqBody); err != nil {
return err
}
@@ -76,6 +77,7 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa
if err != nil {
return fmt.Errorf("unable to create request URL: %w", err)
}
c.Debugf("Executing request: %s %s", method, url.String())
req, err := http.NewRequestWithContext(ctx, method, url.String(), reqReader)
if err != nil {
@@ -84,8 +86,10 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa
if c.useLocking {
c.lock.Lock()
c.Trace("Acquired lock fo request")
defer c.lock.Unlock()
}
c.Trace("Executing request interceptors")
for _, interceptor := range c.interceptors {
if err := interceptor.InterceptRequest(req); err != nil {
return err
@@ -98,20 +102,24 @@ func (c *client) Do(ctx context.Context, method, apiPath string, reqBody interfa
}
defer resp.Body.Close()
c.Trace("Executing response interceptors")
for _, interceptor := range c.interceptors {
if err := interceptor.InterceptResponse(resp); err != nil {
return err
}
}
c.Trace("Checking for errors in response")
if err := c.errorHandler.HandleError(resp); err != nil {
return err
}
if respBody == nil || resp.ContentLength == 0 {
c.Trace("No response body to decode")
return nil
}
c.Trace("Decoding response body")
err = json.NewDecoder(resp.Body).Decode(respBody)
if err != nil {
return fmt.Errorf("unable to decode body: %s %s %w", method, apiPath, err)

View File

@@ -117,6 +117,7 @@ func (c *client) getOldSysInfo(ctx context.Context) (*SysInfo, error) {
// GetSystemInformation retrieves system information, trying the new API first and falling back to the old API if necessary.
func (c *client) GetSystemInformation() (*SysInfo, error) {
c.Trace("Reading system information")
ctx, cancel := c.newRequestContext()
defer cancel()

View File

@@ -10,26 +10,6 @@ import (
var ErrNotFound = errors.New("not found")
// TODO old-style error handling to be removed in future versions.
type APIError struct {
RC string
Message string
}
func (err *APIError) Error() string {
return err.Message
}
func (err *APIError) Is(target error) bool {
var apiError *APIError
if errors.As(target, &apiError) {
if err.RC == apiError.RC && err.Message == apiError.Message {
return true
}
}
return false
}
type Meta struct {
RC string `json:"rc"`
Message string `json:"msg"`
@@ -37,9 +17,9 @@ type Meta struct {
func (m *Meta) error() error {
if m.RC != "ok" {
return &APIError{
RC: m.RC,
Message: m.Message,
return &ServerError{
ErrorCode: m.RC,
Message: m.Message,
}
}

View File

@@ -488,17 +488,16 @@ func TestUrlValidation(t *testing.T) {
func TestValidationModeValidation(t *testing.T) {
t.Parallel()
testCases := []struct {
validationMode validationMode
expectedError string
validationMode ValidationMode
}{
{SoftValidation, ""},
{HardValidation, ""},
{DisableValidation, ""},
{"invalid", "must be one of"},
{SoftValidation},
{HardValidation},
{DisableValidation},
{99},
}
for _, tc := range testCases {
t.Run(string(tc.validationMode), func(t *testing.T) {
t.Run(fmt.Sprintf("%d", tc.validationMode), func(t *testing.T) {
t.Parallel()
// given
cc := &ClientConfig{
@@ -511,12 +510,6 @@ func TestValidationModeValidation(t *testing.T) {
// when
err = v.Validate(cc)
// then
if tc.expectedError != "" {
require.ErrorContains(t, err, tc.expectedError)
return
}
require.NoError(t, err)
})
}
@@ -541,7 +534,7 @@ type validateableBody struct {
func TestValidationModes(t *testing.T) {
t.Parallel()
testCases := []struct {
validationMode validationMode
validationMode ValidationMode
expectedError string
expectRequest bool
}{
@@ -551,7 +544,7 @@ func TestValidationModes(t *testing.T) {
}
for _, tc := range testCases {
t.Run(string(tc.validationMode), func(t *testing.T) {
t.Run(fmt.Sprintf("%d", tc.validationMode), func(t *testing.T) {
t.Parallel()
a := assert.New(t)
// given
@@ -843,11 +836,14 @@ func TestMarshalRequestValid(t *testing.T) {
func TestLoginWithAPIKeyDirect(t *testing.T) {
t.Parallel()
// Create a client manually with the APIKey set.
c := &client{
credentials: APIKeyCredentials{APIKey: "abc"},
}
err := c.Login()
assert.NoError(t, err)
c, err := newBareClient(&ClientConfig{
APIKey: "abc",
URL: testUrl,
})
require.Error(t, err)
err = c.Login()
require.NoError(t, err)
}
func TestHttpTransportCustomizerError(t *testing.T) {