diff --git a/.golangci.yaml b/.golangci.yaml index be6dd8e..4b36afb 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -6,6 +6,7 @@ linters: - 'mnd' - 'nlreturn' - 'tagliatelle' + - 'ireturn' # Temporary - 'cyclop' diff --git a/codegen/client.go.tmpl b/codegen/client.go.tmpl index 65f181c..7164e26 100644 --- a/codegen/client.go.tmpl +++ b/codegen/client.go.tmpl @@ -10,6 +10,8 @@ import ( type {{ .Name }} interface { + Logger + {{- range $k, $v := .Functions }} {{ if $v.Comment }}// {{ $v.Comment }}{{ end }} diff --git a/codegen/version.go b/codegen/version.go index 87bbaf5..27d75ee 100644 --- a/codegen/version.go +++ b/codegen/version.go @@ -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, } diff --git a/docs/advanced_topics.md b/docs/advanced_topics.md index bfcf490..04e0f75 100644 --- a/docs/advanced_topics.md +++ b/docs/advanced_topics.md @@ -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 @@ -143,4 +211,4 @@ For more details on contributing, see the [Contributing Guidelines](https://gith --- This document is intended for advanced users who need deeper control and customization over the UniFi client. -For most users, the basic configuration and usage examples should suffice. \ No newline at end of file +For most users, the basic configuration and usage examples should suffice. \ No newline at end of file diff --git a/docs/migrating_from_upstream.md b/docs/migrating_from_upstream.md index 5c627da..3cb2c4c 100644 --- a/docs/migrating_from_upstream.md +++ b/docs/migrating_from_upstream.md @@ -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. diff --git a/unifi/api_paths.go b/unifi/api_paths.go index 32d923f..0bb13d7 100644 --- a/unifi/api_paths.go +++ b/unifi/api_paths.go @@ -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) diff --git a/unifi/client.generated.go b/unifi/client.generated.go index 741e718..981699c 100644 --- a/unifi/client.generated.go +++ b/unifi/client.generated.go @@ -8,6 +8,7 @@ import ( ) type Client interface { + Logger // BaseURL returns the base URL of the controller. BaseURL() string diff --git a/unifi/client.go b/unifi/client.go index d63ba7c..2e96f3c 100644 --- a/unifi/client.go +++ b/unifi/client.go @@ -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() diff --git a/unifi/logging.go b/unifi/logging.go new file mode 100644 index 0000000..eb9a7b6 --- /dev/null +++ b/unifi/logging.go @@ -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...) +} diff --git a/unifi/requests.go b/unifi/requests.go index 6931427..7e44395 100644 --- a/unifi/requests.go +++ b/unifi/requests.go @@ -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) diff --git a/unifi/sysinfo.go b/unifi/sysinfo.go index 59ec291..66d6e38 100644 --- a/unifi/sysinfo.go +++ b/unifi/sysinfo.go @@ -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() diff --git a/unifi/unifi_errors.go b/unifi/unifi_errors.go index c59760d..1450707 100644 --- a/unifi/unifi_errors.go +++ b/unifi/unifi_errors.go @@ -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, } } diff --git a/unifi/unifi_test.go b/unifi/unifi_test.go index e70a106..8abbb2c 100644 --- a/unifi/unifi_test.go +++ b/unifi/unifi_test.go @@ -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) {