diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36d4a5e..893c74e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - go-version: ['1.18', '1.19', '1.20', '1.21', '1.22', 'stable'] + go-version: ['1.22', 'stable'] steps: - name: Set up Go ${{ matrix.go-version }} uses: actions/setup-go@v3 @@ -46,7 +46,9 @@ jobs: - name: Get dependencies run: go mod tidy - name: Tests - run: go test ./... -v -cpu 2 -timeout 30s -race -cover -coverprofile=coverage.txt -covermode=atomic + run: | + go install gotest.tools/gotestsum@latest + gotestsum --format testdox ./... -v -cpu 2 -timeout 30s -race -cover -coverprofile=coverage.txt -covermode=atomic - name: Coverage run: | go install github.com/axw/gocov/gocov@latest diff --git a/README.md b/README.md index 4bb3cdb..c500d45 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![Coverage](https://codecov.io/gh/retailcrm/mg-transport-core/branch/master/graph/badge.svg?logo=codecov&logoColor=white)](https://codecov.io/gh/retailcrm/mg-transport-core) [![GitHub release](https://img.shields.io/github/release/retailcrm/mg-transport-core.svg?logo=github&logoColor=white)](https://github.com/retailcrm/mg-transport-core/releases) [![Go Report Card](https://goreportcard.com/badge/github.com/retailcrm/mg-transport-core)](https://goreportcard.com/report/github.com/retailcrm/mg-transport-core) -[![GoLang version](https://img.shields.io/badge/go->=1.12-blue.svg?logo=go&logoColor=white)](https://golang.org/dl/) +[![GoLang version](https://img.shields.io/badge/go->=1.22-blue.svg?logo=go&logoColor=white)](https://golang.org/dl/) [![pkg.go.dev](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white)](https://pkg.go.dev/github.com/retailcrm/mg-transport-core/core) This library provides different functions like error-reporting, logging, localization, etc. in order to make it easier to create transports. diff --git a/cmd/transport-core-tool/main.go b/cmd/transport-core-tool/main.go index 2fdcfd1..7787f72 100644 --- a/cmd/transport-core-tool/main.go +++ b/cmd/transport-core-tool/main.go @@ -32,8 +32,7 @@ func main() { if _, err := parser.Parse(); err != nil { if flagsErr, ok := err.(*flags.Error); ok && flagsErr.Type == flags.ErrHelp { // nolint:errorlint os.Exit(0) - } else { - os.Exit(1) } + os.Exit(1) } } diff --git a/core/config/config.go b/core/config/config.go index e49edcc..d236003 100644 --- a/core/config/config.go +++ b/core/config/config.go @@ -1,7 +1,7 @@ package config import ( - "io/ioutil" + "os" "path/filepath" "time" @@ -14,6 +14,7 @@ type Configuration interface { GetVersion() string GetSentryDSN() string GetLogLevel() logging.Level + GetLogFormat() string GetHTTPConfig() HTTPServerConfig GetZabbixConfig() ZabbixConfig GetDBConfig() DatabaseConfig @@ -44,6 +45,7 @@ type Config struct { Database DatabaseConfig `yaml:"database"` UpdateInterval int `yaml:"update_interval"` LogLevel logging.Level `yaml:"log_level"` + LogFormat string `yaml:"log_format"` Debug bool `yaml:"debug"` } @@ -130,7 +132,7 @@ func (c *Config) GetConfigData(path string) []byte { panic(err) } - source, err := ioutil.ReadFile(path) + source, err := os.ReadFile(path) if err != nil { panic(err) } @@ -153,6 +155,10 @@ func (c Config) GetLogLevel() logging.Level { return c.LogLevel } +func (c Config) GetLogFormat() string { + return c.LogFormat +} + // GetTransportInfo transport basic data. func (c Config) GetTransportInfo() InfoInterface { return c.TransportInfo diff --git a/core/config/config_test.go b/core/config/config_test.go index 0b143d4..8498ae0 100644 --- a/core/config/config_test.go +++ b/core/config/config_test.go @@ -1,7 +1,6 @@ package config import ( - "io/ioutil" "os" "path" "testing" @@ -38,6 +37,7 @@ transport_info: sentry_dsn: dsn string log_level: 5 +log_format: console debug: true update_interval: 24 @@ -53,7 +53,7 @@ config_aws: bucket: bucket folder_name: folder content_type: image/jpeg`) - err := ioutil.WriteFile(testConfigFile, c.data, os.ModePerm) + err := os.WriteFile(testConfigFile, c.data, os.ModePerm) require.Nil(c.T(), err) c.config = NewConfig(testConfigFile) @@ -90,6 +90,10 @@ func (c *ConfigTest) Test_GetLogLevel() { assert.Equal(c.T(), logging.Level(5), c.config.GetLogLevel()) } +func (c *ConfigTest) Test_GetLogFormat() { + assert.Equal(c.T(), "console", c.config.GetLogFormat()) +} + func (c *ConfigTest) Test_IsDebug() { assert.Equal(c.T(), true, c.config.IsDebug()) } diff --git a/core/db/migration_generator.go b/core/db/migration_generator.go index b078ad0..79b9967 100644 --- a/core/db/migration_generator.go +++ b/core/db/migration_generator.go @@ -51,7 +51,7 @@ func (x *NewMigrationCommand) FileExists(filename string) bool { } // Execute migration generator command. -func (x *NewMigrationCommand) Execute(args []string) error { +func (x *NewMigrationCommand) Execute(_ []string) error { tpl, err := template.New("migration").Parse(migrationTemplate) if err != nil { return fmt.Errorf("fatal: cannot parse base migration template: %w", err) diff --git a/core/db/migration_generator_test.go b/core/db/migration_generator_test.go index 1d86ac5..03723ac 100644 --- a/core/db/migration_generator_test.go +++ b/core/db/migration_generator_test.go @@ -2,7 +2,6 @@ package db import ( "fmt" - "io/ioutil" "log" "math/rand" "os" @@ -36,7 +35,7 @@ func (s *MigrationGeneratorSuite) Test_FileExists() { func (s *MigrationGeneratorSuite) Test_Execute() { found := false assert.NoError(s.T(), s.command.Execute([]string{})) - files, err := ioutil.ReadDir(s.command.Directory) + files, err := os.ReadDir(s.command.Directory) if err != nil { log.Fatal(err) } diff --git a/core/db/models/connection.go b/core/db/models/connection.go index 8bb55d3..0f7e0a7 100644 --- a/core/db/models/connection.go +++ b/core/db/models/connection.go @@ -17,3 +17,10 @@ type Connection struct { ID int `gorm:"primary_key" json:"id"` Active bool `json:"active,omitempty"` } + +func (c Connection) Address() string { + if c.PublicURL != "" { + return c.PublicURL + } + return c.URL +} diff --git a/core/db/models/user.go b/core/db/models/user.go index 5a7b4b0..be82382 100644 --- a/core/db/models/user.go +++ b/core/db/models/user.go @@ -14,11 +14,14 @@ type User struct { // TableName will return table name for User // It will not work if User is not embedded, but mapped as another type -// type MyUser User // will not work +// +// type MyUser User // will not work +// // but -// type MyUser struct { // will work -// User -// } +// +// type MyUser struct { // will work +// User +// } func (User) TableName() string { return "mg_user" } diff --git a/core/doc.go b/core/doc.go index b1916a4..4bbe25e 100644 --- a/core/doc.go +++ b/core/doc.go @@ -2,6 +2,7 @@ Package core provides different functions like error-reporting, logging, localization, etc. to make it easier to create transports. Usage: + package main import ( @@ -36,14 +37,19 @@ Usage: } } -Resource embedding +# Resource embedding packr can be used to provide resource embedding, see: + https://github.com/gobuffalo/packr/tree/master/v2 + In order to use packr you must follow instruction, and provide boxes with templates, translations and assets to library. You can find instruction here: + https://github.com/gobuffalo/packr/tree/master/v2#library-installation + Example of usage: + package main import ( @@ -104,10 +110,12 @@ Example of usage: } } -Migration generator +# Migration generator This library contains helper tool for transports. You can install it via go: + $ go get -u github.com/retailcrm/mg-transport-core/cmd/transport-core-tool + Currently, it only can generate new migrations for your transport. Copyright (c) 2019 RetailDriver LLC. Usage of this source code is governed by a MIT license. diff --git a/core/domains.go b/core/domains.go index 2b14d63..46e1529 100644 --- a/core/domains.go +++ b/core/domains.go @@ -2,7 +2,7 @@ package core import ( "encoding/json" - "io/ioutil" + "io" "net/http" ) @@ -40,7 +40,7 @@ func getDomainsByStore(store string) []Domain { return nil } - respBody, readErr := ioutil.ReadAll(resp.Body) + respBody, readErr := io.ReadAll(resp.Body) if readErr != nil { return nil diff --git a/core/engine.go b/core/engine.go index bd3b38a..72dfd48 100644 --- a/core/engine.go +++ b/core/engine.go @@ -7,14 +7,15 @@ import ( "io/fs" "net/http" "sync" + "time" "github.com/blacked/go-zabbix" "github.com/getsentry/sentry-go" "github.com/gin-gonic/gin" "github.com/gorilla/securecookie" "github.com/gorilla/sessions" - "github.com/op/go-logging" - "github.com/retailcrm/zabbix-metrics-collector" + metrics "github.com/retailcrm/zabbix-metrics-collector" + "go.uber.org/zap" "golang.org/x/text/language" "github.com/retailcrm/mg-transport-core/v2/core/config" @@ -26,12 +27,14 @@ import ( "github.com/retailcrm/mg-transport-core/v2/core/logger" ) +const DefaultHTTPClientTimeout time.Duration = 30 + var boolTrue = true // DefaultHTTPClientConfig is a default config for HTTP client. It will be used by Engine for building HTTP client // if HTTP client config is not present in the configuration. var DefaultHTTPClientConfig = &config.HTTPClientConfig{ - Timeout: 30, + Timeout: DefaultHTTPClientTimeout, SSLVerification: &boolTrue, } @@ -62,16 +65,15 @@ func (a AppInfo) Release() string { // Engine struct. type Engine struct { - logger logger.Logger - AppInfo AppInfo - Sessions sessions.Store - LogFormatter logging.Formatter - Config config.Configuration - Zabbix metrics.Transport - ginEngine *gin.Engine - csrf *middleware.CSRF - httpClient *http.Client - jobManager *JobManager + logger logger.Logger + AppInfo AppInfo + Sessions sessions.Store + Config config.Configuration + Zabbix metrics.Transport + ginEngine *gin.Engine + csrf *middleware.CSRF + httpClient *http.Client + jobManager *JobManager db.ORM Localizer util.Utils @@ -112,11 +114,6 @@ func (e *Engine) initGin() { e.buildSentryConfig() e.InitSentrySDK() r.Use(e.SentryMiddlewares()...) - - if e.Config.IsDebug() { - r.Use(gin.Logger()) - } - r.Use(e.LocalizationMiddleware()) e.ginEngine = r } @@ -133,9 +130,6 @@ func (e *Engine) Prepare() *Engine { if e.DefaultError == "" { e.DefaultError = "error" } - if e.LogFormatter == nil { - e.LogFormatter = logger.DefaultLogFormatter() - } if e.LocaleMatcher == nil { e.LocaleMatcher = DefaultLocalizerMatcher() } @@ -150,9 +144,14 @@ func (e *Engine) Prepare() *Engine { e.Localizer.Preload(e.PreloadLanguages) } + logFormat := "json" + if format := e.Config.GetLogFormat(); format != "" { + logFormat = format + } + e.CreateDB(e.Config.GetDBConfig()) e.ResetUtils(e.Config.GetAWSConfig(), e.Config.IsDebug(), 0) - e.SetLogger(logger.NewStandard(e.Config.GetTransportInfo().GetCode(), e.Config.GetLogLevel(), e.LogFormatter)) + e.SetLogger(logger.NewDefault(logFormat, e.Config.IsDebug())) e.Sentry.Localizer = &e.Localizer e.Utils.Logger = e.Logger() e.Sentry.Logger = e.Logger() @@ -175,7 +174,25 @@ func (e *Engine) UseZabbix(collectors []metrics.Collector) *Engine { } cfg := e.Config.GetZabbixConfig() sender := zabbix.NewSender(cfg.ServerHost, cfg.ServerPort) - e.Zabbix = metrics.NewZabbix(collectors, sender, cfg.Host, cfg.Interval, e.Logger()) + e.Zabbix = metrics.NewZabbix(collectors, sender, cfg.Host, cfg.Interval, logger.ZabbixCollectorAdapter(e.Logger())) + return e +} + +// HijackGinLogs will take control of GIN debug logs and will convert them into structured logs. +// It will also affect default logging middleware. Use logger.GinMiddleware to circumvent this. +func (e *Engine) HijackGinLogs() *Engine { + if e.Logger() == nil { + return e + } + gin.DefaultWriter = logger.WriterAdapter(e.Logger(), zap.DebugLevel) + gin.DefaultErrorWriter = logger.WriterAdapter(e.Logger(), zap.ErrorLevel) + gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) { + e.Logger().Debug("route", + zap.String(logger.HTTPMethodAttr, httpMethod), + zap.String("path", absolutePath), + zap.String(logger.HandlerAttr, handlerName), + zap.Int("handlerCount", nuHandlers)) + } return e } @@ -247,6 +264,9 @@ func (e *Engine) SetLogger(l logger.Logger) *Engine { e.mutex.Lock() defer e.mutex.Unlock() + if !e.prepared && e.logger != nil { + return e + } e.logger = l return e } @@ -262,10 +282,9 @@ func (e *Engine) BuildHTTPClient(certs *x509.CertPool, replaceDefault ...bool) * if err != nil { panic(err) - } else { - e.httpClient = client } + e.httpClient = client return e } diff --git a/core/engine_test.go b/core/engine_test.go index 259ef92..ab9bbd2 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -6,7 +6,7 @@ import ( "database/sql" "fmt" "html/template" - "io/ioutil" + "io" "net/http" "net/url" "os" @@ -123,7 +123,6 @@ func (e *EngineTest) Test_Prepare() { assert.True(e.T(), e.engine.prepared) assert.NotNil(e.T(), e.engine.Config) assert.NotEmpty(e.T(), e.engine.DefaultError) - assert.NotEmpty(e.T(), e.engine.LogFormatter) assert.NotEmpty(e.T(), e.engine.LocaleMatcher) assert.False(e.T(), e.engine.isUnd(e.engine.Localizer.LanguageTag)) assert.NotNil(e.T(), e.engine.DB) @@ -253,7 +252,7 @@ func (e *EngineTest) Test_SetLogger() { defer func() { e.engine.logger = origLogger }() - e.engine.logger = &logger.StandardLogger{} + e.engine.logger = logger.NewNil() e.engine.SetLogger(nil) assert.NotNil(e.T(), e.engine.logger) } @@ -366,7 +365,7 @@ func (e *EngineTest) Test_GetCSRFToken() { URL: &url.URL{ RawQuery: "", }, - Body: ioutil.NopCloser(bytes.NewReader([]byte{})), + Body: io.NopCloser(bytes.NewReader([]byte{})), Header: http.Header{"X-CSRF-Token": []string{"token"}}, }} c.Set("csrf_token", "token") diff --git a/core/healthcheck/counter.go b/core/healthcheck/counter.go index e8e1ede..86ee3a7 100644 --- a/core/healthcheck/counter.go +++ b/core/healthcheck/counter.go @@ -10,7 +10,7 @@ import ( const DefaultResetPeriod = time.Minute * 15 // AtomicCounter is a default Counter implementation. -// It uses atomics under the hood (hence the name) and can be configured with custom reset timeout and +// It uses atomics under the hood (hence the name) and can be configured with custom reset timeout and. type AtomicCounter struct { name atomic.String msg atomic.String diff --git a/core/healthcheck/iface.go b/core/healthcheck/iface.go index cf88d2d..c9c2486 100644 --- a/core/healthcheck/iface.go +++ b/core/healthcheck/iface.go @@ -1,7 +1,7 @@ package healthcheck var ( - // compile-time checks to ensure that implementations are compatible with the interface + // compile-time checks to ensure that implementations are compatible with the interface. _ = Storage(&SyncMapStorage{}) _ = Counter(&AtomicCounter{}) _ = Processor(CounterProcessor{}) diff --git a/core/healthcheck/processor.go b/core/healthcheck/processor.go index fc11ccd..f7f4216 100644 --- a/core/healthcheck/processor.go +++ b/core/healthcheck/processor.go @@ -2,6 +2,7 @@ package healthcheck import ( "github.com/retailcrm/mg-transport-core/v2/core/logger" + "go.uber.org/zap" ) const ( @@ -29,19 +30,19 @@ type CounterProcessor struct { func (c CounterProcessor) Process(id int, counter Counter) bool { // nolint:varnamelen if counter.IsFailed() { if counter.IsFailureProcessed() { - c.debugLog("skipping counter id=%d because its failure is already processed", id) + c.debugLog("skipping counter because its failure is already processed", zap.Int(logger.CounterIDAttr, id)) return true } apiURL, apiKey, _, exists := c.ConnectionDataProvider(id) if !exists { - c.debugLog("cannot find connection data for counter id=%d", id) + c.debugLog("cannot find connection data for counter", zap.Int(logger.CounterIDAttr, id)) return true } err := c.Notifier(apiURL, apiKey, counter.Message()) if err != nil { - c.debugLog("cannot send notification for counter id=%d: %s (message: %s)", - id, err, counter.Message()) + c.debugLog("cannot send notification for counter", + zap.Int(logger.CounterIDAttr, id), logger.Err(err), zap.String(logger.FailureMessageAttr, counter.Message())) } counter.FailureProcessed() return true @@ -53,7 +54,8 @@ func (c CounterProcessor) Process(id int, counter Counter) bool { // nolint:varn // Ignore this counter for now because total count of requests is less than minimal count. // The results may not be representative. if (succeeded + failed) < c.MinRequests { - c.debugLog("skipping counter id=%d because it has fewer than %d requests", id, c.MinRequests) + c.debugLog("skipping counter because it has too few requests", + zap.Int(logger.CounterIDAttr, id), zap.Any("minRequests", c.MinRequests)) return true } @@ -72,13 +74,13 @@ func (c CounterProcessor) Process(id int, counter Counter) bool { // nolint:varn apiURL, apiKey, lang, exists := c.ConnectionDataProvider(id) if !exists { - c.debugLog("cannot find connection data for counter id=%d", id) + c.debugLog("cannot find connection data for counter", zap.Int(logger.CounterIDAttr, id)) return true } err := c.Notifier(apiURL, apiKey, c.getErrorText(counter.Name(), c.Error, lang)) if err != nil { - c.debugLog("cannot send notification for counter id=%d: %s (message: %s)", - id, err, counter.Message()) + c.debugLog("cannot send notification for counter", + zap.Int(logger.CounterIDAttr, id), logger.Err(err), zap.String(logger.FailureMessageAttr, counter.Message())) } counter.CountersProcessed() return true @@ -96,6 +98,6 @@ func (c CounterProcessor) getErrorText(name, msg, lang string) string { func (c CounterProcessor) debugLog(msg string, args ...interface{}) { if c.Debug { - c.Logger.Debugf(msg, args...) + c.Logger.Debug(msg, logger.AnyZapFields(args)...) } } diff --git a/core/healthcheck/processor_test.go b/core/healthcheck/processor_test.go index 9774310..e990629 100644 --- a/core/healthcheck/processor_test.go +++ b/core/healthcheck/processor_test.go @@ -45,7 +45,7 @@ func (t *CounterProcessorTest) localizer() NotifyMessageLocalizer { } func (t *CounterProcessorTest) new( - nf NotifyFunc, pr ConnectionDataProvider, noLocalizer ...bool) (Processor, testutil.BufferedLogger) { + nf NotifyFunc, pr ConnectionDataProvider, noLocalizer ...bool) (Processor, *testutil.JSONRecordScanner) { loc := t.localizer() if len(noLocalizer) > 0 && noLocalizer[0] { loc = nil @@ -61,7 +61,7 @@ func (t *CounterProcessorTest) new( FailureThreshold: DefaultFailureThreshold, MinRequests: DefaultMinRequests, Debug: true, - }, log + }, testutil.NewJSONRecordScanner(log) } func (t *CounterProcessorTest) notifier(err ...error) *notifierMock { @@ -95,7 +95,12 @@ func (t *CounterProcessorTest) Test_FailureProcessed() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), "skipping counter id=1 because its failure is already processed") + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "skipping counter because its failure is already processed") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) } func (t *CounterProcessorTest) Test_CounterFailed_CannotFindConnection() { @@ -107,7 +112,12 @@ func (t *CounterProcessorTest) Test_CounterFailed_CannotFindConnection() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), "cannot find connection data for counter id=1") + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "cannot find connection data for counter") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) } func (t *CounterProcessorTest) Test_CounterFailed_ErrWhileNotifying() { @@ -121,7 +131,14 @@ func (t *CounterProcessorTest) Test_CounterFailed_ErrWhileNotifying() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), "cannot send notification for counter id=1: http status code: 500 (message: error message)") + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "cannot send notification for counter") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) + t.Assert().Equal("http status code: 500", logs[0].Context["error"]) + t.Assert().Equal("error message", logs[0].Context["failureMessage"]) t.Assert().Equal(t.apiURL, n.apiURL) t.Assert().Equal(t.apiKey, n.apiKey) t.Assert().Equal("error message", n.message) @@ -138,7 +155,10 @@ func (t *CounterProcessorTest) Test_CounterFailed_SentNotification() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Empty(log.String()) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 0) t.Assert().Equal(t.apiURL, n.apiURL) t.Assert().Equal(t.apiKey, n.apiKey) t.Assert().Equal("error message", n.message) @@ -154,8 +174,13 @@ func (t *CounterProcessorTest) Test_TooFewRequests() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), - fmt.Sprintf("skipping counter id=%d because it has fewer than %d requests", 1, DefaultMinRequests)) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "skipping counter because it has too few requests") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) + t.Assert().Equal(float64(DefaultMinRequests), logs[0].Context["minRequests"]) } func (t *CounterProcessorTest) Test_ThresholdNotPassed() { @@ -170,7 +195,10 @@ func (t *CounterProcessorTest) Test_ThresholdNotPassed() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Empty(log.String()) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 0) t.Assert().Empty(n.message) } @@ -185,7 +213,10 @@ func (t *CounterProcessorTest) Test_ThresholdPassed_AlreadyProcessed() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Empty(log.String()) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 0) t.Assert().Empty(n.message) } @@ -200,7 +231,12 @@ func (t *CounterProcessorTest) Test_ThresholdPassed_NoConnectionFound() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), "cannot find connection data for counter id=1") + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "cannot find connection data for counter") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) t.Assert().Empty(n.message) } @@ -218,7 +254,13 @@ func (t *CounterProcessorTest) Test_ThresholdPassed_NotifyingError() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Contains(log.String(), "cannot send notification for counter id=1: unknown error (message: )") + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 1) + t.Assert().Contains(logs[0].Message, "cannot send notification for counter") + t.Assert().Equal(float64(1), logs[0].Context["counterId"]) + t.Assert().Equal("unknown error", logs[0].Context["error"]) t.Assert().Equal(`default error [{"Name":"MockedCounter"}]`, n.message) } @@ -235,7 +277,10 @@ func (t *CounterProcessorTest) Test_ThresholdPassed_NotificationSent() { p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Empty(log.String()) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 0) t.Assert().Equal(`default error [{"Name":"MockedCounter"}]`, n.message) } @@ -252,7 +297,10 @@ func (t *CounterProcessorTest) Test_ThresholdPassed_NotificationSent_NoLocalizer p.Process(1, c) c.AssertExpectations(t.T()) - t.Assert().Empty(log.String()) + + logs, err := log.ScanAll() + t.Require().NoError(err) + t.Require().Len(logs, 0) t.Assert().Equal(`default error`, n.message) } diff --git a/core/job_manager.go b/core/job_manager.go index fac84de..4a734cc 100644 --- a/core/job_manager.go +++ b/core/job_manager.go @@ -7,6 +7,7 @@ import ( "time" "github.com/retailcrm/mg-transport-core/v2/core/logger" + "go.uber.org/zap" ) // JobFunc is empty func which should be executed in a parallel goroutine. @@ -46,7 +47,7 @@ type Job struct { // SetLogger(logger). // SetLogging(false) // _ = manager.RegisterJob("updateTokens", &Job{ -// Command: func(log logger.Logger) error { +// Command: func(log logger.LoggerOld) error { // // logic goes here... // logger.Info("All tokens were updated successfully") // return nil @@ -73,6 +74,7 @@ func (j *Job) getWrappedFunc(name string, log logger.Logger) func(callback JobAf } }() + log = log.With(logger.Handler(name)) err := j.Command(log) if err != nil && j.ErrorHandler != nil { j.ErrorHandler(name, err, log) @@ -153,7 +155,7 @@ func NewJobManager() *JobManager { func DefaultJobErrorHandler() JobErrorHandler { return func(name string, err error, log logger.Logger) { if err != nil && name != "" { - log.Errorf("Job `%s` errored with an error: `%s`", name, err.Error()) + log.Error("job failed with an error", zap.String("job", name), logger.Err(err)) } } } @@ -162,7 +164,7 @@ func DefaultJobErrorHandler() JobErrorHandler { func DefaultJobPanicHandler() JobPanicHandler { return func(name string, recoverValue interface{}, log logger.Logger) { if recoverValue != nil && name != "" { - log.Errorf("Job `%s` panicked with value: `%#v`", name, recoverValue) + log.Error("job panicked with the value", zap.String("job", name), zap.Any("value", recoverValue)) } } } diff --git a/core/job_manager_test.go b/core/job_manager_test.go index 6da22de..ac3ce2a 100644 --- a/core/job_manager_test.go +++ b/core/job_manager_test.go @@ -9,10 +9,11 @@ import ( "testing" "time" - "github.com/op/go-logging" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" "github.com/retailcrm/mg-transport-core/v2/core/logger" ) @@ -25,7 +26,7 @@ type JobTest struct { executeErr chan error panicValue chan interface{} lastLog string - lastMsgLevel logging.Level + lastMsgLevel zapcore.Level syncBool bool } @@ -36,68 +37,94 @@ type JobManagerTest struct { syncRunnerFlag bool } -type callbackLoggerFunc func(level logging.Level, format string, args ...interface{}) +type callbackLoggerFunc func(level zapcore.Level, msg string, args ...zap.Field) type callbackLogger struct { - fn callbackLoggerFunc + fields []zap.Field + fn callbackLoggerFunc } -func (n *callbackLogger) Fatal(args ...interface{}) { - n.fn(logging.CRITICAL, "", args...) +func (n *callbackLogger) Check(_ zapcore.Level, _ string) *zapcore.CheckedEntry { + return &zapcore.CheckedEntry{} } -func (n *callbackLogger) Fatalf(format string, args ...interface{}) { - n.fn(logging.CRITICAL, format, args...) +func (n *callbackLogger) DPanic(msg string, fields ...zap.Field) { + n.fn(zap.PanicLevel, msg, fields...) } -func (n *callbackLogger) Panic(args ...interface{}) { - n.fn(logging.CRITICAL, "", args...) -} -func (n *callbackLogger) Panicf(format string, args ...interface{}) { - n.fn(logging.CRITICAL, format, args...) +func (n *callbackLogger) Panic(msg string, fields ...zap.Field) { + n.fn(zap.PanicLevel, msg, fields...) } -func (n *callbackLogger) Critical(args ...interface{}) { - n.fn(logging.CRITICAL, "", args...) +func (n *callbackLogger) Fatal(msg string, fields ...zap.Field) { + n.fn(zap.FatalLevel, msg, fields...) } -func (n *callbackLogger) Criticalf(format string, args ...interface{}) { - n.fn(logging.CRITICAL, format, args...) +func (n *callbackLogger) Sync() error { + return nil } -func (n *callbackLogger) Error(args ...interface{}) { - n.fn(logging.ERROR, "", args...) -} -func (n *callbackLogger) Errorf(format string, args ...interface{}) { - n.fn(logging.ERROR, format, args...) +func (n *callbackLogger) clone() *callbackLogger { + return &callbackLogger{fn: n.fn, fields: n.fields} } -func (n *callbackLogger) Warning(args ...interface{}) { - n.fn(logging.WARNING, "", args...) -} -func (n *callbackLogger) Warningf(format string, args ...interface{}) { - n.fn(logging.WARNING, format, args...) +func (n *callbackLogger) cloneWithFields(fields []zap.Field) *callbackLogger { + cl := &callbackLogger{fn: n.fn, fields: n.fields} + existing := cl.fields + if len(existing) == 0 { + cl.fields = fields + return cl + } + cl.fields = append(existing, fields...) // nolint:gocritic + return cl } -func (n *callbackLogger) Notice(args ...interface{}) { - n.fn(logging.NOTICE, "", args...) -} -func (n *callbackLogger) Noticef(format string, args ...interface{}) { - n.fn(logging.NOTICE, format, args...) +func (n *callbackLogger) Level() zapcore.Level { + return zapcore.DebugLevel } -func (n *callbackLogger) Info(args ...interface{}) { - n.fn(logging.INFO, "", args...) -} -func (n *callbackLogger) Infof(format string, args ...interface{}) { - n.fn(logging.INFO, format, args...) +func (n *callbackLogger) With(args ...zap.Field) logger.Logger { + return n.cloneWithFields(args) } -func (n *callbackLogger) Debug(args ...interface{}) { - n.fn(logging.DEBUG, "", args...) +func (n *callbackLogger) WithLazy(args ...zap.Field) logger.Logger { + return n.cloneWithFields(args) } -func (n *callbackLogger) Debugf(format string, args ...interface{}) { - n.fn(logging.DEBUG, format, args...) + +func (n *callbackLogger) WithGroup(_ string) logger.Logger { + return n +} + +func (n *callbackLogger) ForHandler(_ any) logger.Logger { + return n +} + +func (n *callbackLogger) ForConnection(_ any) logger.Logger { + return n +} + +func (n *callbackLogger) ForAccount(_ any) logger.Logger { + return n +} + +func (n *callbackLogger) Log(level zapcore.Level, msg string, args ...zap.Field) { + n.fn(level, msg, args...) +} + +func (n *callbackLogger) Debug(msg string, args ...zap.Field) { + n.Log(zap.DebugLevel, msg, args...) +} + +func (n *callbackLogger) Info(msg string, args ...zap.Field) { + n.Log(zap.InfoLevel, msg, args...) +} + +func (n *callbackLogger) Warn(msg string, args ...zap.Field) { + n.Log(zap.WarnLevel, msg, args...) +} + +func (n *callbackLogger) Error(msg string, args ...zap.Field) { + n.Log(zap.ErrorLevel, msg, args...) } func TestJob(t *testing.T) { @@ -115,9 +142,9 @@ func TestDefaultJobErrorHandler(t *testing.T) { fn := DefaultJobErrorHandler() require.NotNil(t, fn) - fn("job", errors.New("test"), &callbackLogger{fn: func(level logging.Level, s string, i ...interface{}) { + fn("job", errors.New("test"), &callbackLogger{fn: func(level zapcore.Level, s string, i ...zap.Field) { require.Len(t, i, 2) - assert.Equal(t, fmt.Sprintf("%s", i[1]), "test") + assert.Equal(t, "error=test", fmt.Sprintf("%s=%v", i[1].Key, i[1].Interface)) }}) } @@ -128,9 +155,9 @@ func TestDefaultJobPanicHandler(t *testing.T) { fn := DefaultJobPanicHandler() require.NotNil(t, fn) - fn("job", errors.New("test"), &callbackLogger{fn: func(level logging.Level, s string, i ...interface{}) { + fn("job", errors.New("test"), &callbackLogger{fn: func(level zapcore.Level, s string, i ...zap.Field) { require.Len(t, i, 2) - assert.Equal(t, fmt.Sprintf("%s", i[1]), "test") + assert.Equal(t, "value=test", fmt.Sprintf("%s=%s", i[1].Key, i[1].Interface)) }}) } @@ -147,7 +174,7 @@ func (t *JobTest) testPanicHandler() JobPanicHandler { } func (t *JobTest) testLogger() logger.Logger { - return &callbackLogger{fn: func(level logging.Level, format string, args ...interface{}) { + return &callbackLogger{fn: func(level zapcore.Level, format string, args ...zap.Field) { if format == "" { var sb strings.Builder sb.Grow(3 * len(args)) // nolint:gomnd @@ -159,7 +186,12 @@ func (t *JobTest) testLogger() logger.Logger { format = strings.TrimRight(sb.String(), " ") } - t.lastLog = fmt.Sprintf(format, args...) + anyFields := []any{} + for _, item := range args { + anyFields = append(anyFields, item.Key+"="+fmt.Sprint(item.Interface)) + } + + t.lastLog = fmt.Sprintf(format, anyFields...) t.lastMsgLevel = level }} } @@ -256,11 +288,11 @@ func (t *JobTest) oncePanicJob() { } func (t *JobTest) regularJob() { - rand.Seed(time.Now().UnixNano()) + r := rand.New(rand.NewSource(time.Now().UnixNano())) // nolint:gosec t.job = &Job{ Command: func(log logger.Logger) error { t.executedChan <- true - t.randomNumber <- rand.Int() // nolint:gosec + t.randomNumber <- r.Int() // nolint:gosec return nil }, ErrorHandler: t.testErrorHandler(), @@ -271,7 +303,6 @@ func (t *JobTest) regularJob() { } func (t *JobTest) regularSyncJob() { - rand.Seed(time.Now().UnixNano()) t.job = &Job{ Command: func(log logger.Logger) error { t.syncBool = true @@ -404,11 +435,11 @@ func (t *JobManagerTest) WaitForJob() bool { func (t *JobManagerTest) Test_SetLogger() { t.manager.logger = nil - t.manager.SetLogger(logger.NewStandard("test", logging.ERROR, logger.DefaultLogFormatter())) - assert.IsType(t.T(), &logger.StandardLogger{}, t.manager.logger) + t.manager.SetLogger(logger.NewDefault("json", true)) + assert.IsType(t.T(), &logger.Default{}, t.manager.logger) t.manager.SetLogger(nil) - assert.IsType(t.T(), &logger.StandardLogger{}, t.manager.logger) + assert.IsType(t.T(), &logger.Default{}, t.manager.logger) } func (t *JobManagerTest) Test_SetLogging() { diff --git a/core/localizer.go b/core/localizer.go index 5dbfcf7..355d86d 100644 --- a/core/localizer.go +++ b/core/localizer.go @@ -3,7 +3,7 @@ package core import ( "html/template" "io/fs" - "io/ioutil" + "os" "path" "sync" @@ -90,7 +90,8 @@ type CloneableLocalizer interface { // NewLocalizer returns localizer instance with specified parameters. // Usage: -// NewLocalizer(language.English, DefaultLocalizerMatcher(), "translations") +// +// NewLocalizer(language.English, DefaultLocalizerMatcher(), "translations") func NewLocalizer(locale language.Tag, matcher language.Matcher, translationsPath string) LocalizerInterface { localizer := &Localizer{ i18nStorage: &sync.Map{}, @@ -106,7 +107,9 @@ func NewLocalizer(locale language.Tag, matcher language.Matcher, translationsPat // NewLocalizerFS returns localizer instance with specified parameters. // Usage: -// NewLocalizerFS(language.English, DefaultLocalizerMatcher(), translationsFS) +// +// NewLocalizerFS(language.English, DefaultLocalizerMatcher(), translationsFS) +// // TODO This code should be covered with tests. func NewLocalizerFS( locale language.Tag, matcher language.Matcher, translationsFS fs.FS, @@ -161,9 +164,10 @@ func (l *Localizer) Clone() CloneableLocalizer { // Because of that all Localizer instances from this middleware will share *same* mutex. This mutex is used to wrap // i18n.Bundle methods (those aren't goroutine-safe to use). // Usage: -// engine := gin.New() -// localizer := NewLocalizer("en", DefaultLocalizerMatcher(), "translations") -// engine.Use(localizer.LocalizationMiddleware()) +// +// engine := gin.New() +// localizer := NewLocalizer("en", DefaultLocalizerMatcher(), "translations") +// engine.Use(localizer.LocalizationMiddleware()) func (l *Localizer) LocalizationMiddleware() gin.HandlerFunc { return func(c *gin.Context) { clone := l.Clone().(LocaleControls) @@ -174,15 +178,21 @@ func (l *Localizer) LocalizationMiddleware() gin.HandlerFunc { // LocalizationFuncMap returns template.FuncMap (html template is used) with one method - trans // Usage in code: -// engine := gin.New() -// engine.FuncMap = localizer.LocalizationFuncMap() +// +// engine := gin.New() +// engine.FuncMap = localizer.LocalizationFuncMap() +// // or (with multitemplate) -// renderer := multitemplate.NewRenderer() -// funcMap := localizer.LocalizationFuncMap() -// renderer.AddFromFilesFuncs("index", funcMap, "template/index.html") +// +// renderer := multitemplate.NewRenderer() +// funcMap := localizer.LocalizationFuncMap() +// renderer.AddFromFilesFuncs("index", funcMap, "template/index.html") +// // funcMap must be passed for every .AddFromFilesFuncs call // Usage in templates: -//

{{"need_login_msg" | trans}} +// +//

{{"need_login_msg" | trans}} +// // You can borrow FuncMap from this method and add your functions to it. func (l *Localizer) LocalizationFuncMap() template.FuncMap { return template.FuncMap{ @@ -196,7 +206,7 @@ func (l *Localizer) LocalizationFuncMap() template.FuncMap { parts = append(parts, "") } - partsMap := make(map[string]interface{}, len(parts)/2) + partsMap := make(map[string]interface{}, len(parts)/2) // nolint:gomnd for i := 0; i < len(parts)-1; i += 2 { partsMap[parts[i]] = parts[i+1] @@ -239,7 +249,7 @@ func (l *Localizer) loadTranslationsToBundle(i18nBundle *i18n.Bundle) { // LoadTranslations will load all translation files from translations directory. func (l *Localizer) loadFromDirectory(i18nBundle *i18n.Bundle) error { - files, err := ioutil.ReadDir(l.TranslationsPath) + files, err := os.ReadDir(l.TranslationsPath) if err != nil { return err } diff --git a/core/localizer_test.go b/core/localizer_test.go index 57c5cc8..ec41cb8 100644 --- a/core/localizer_test.go +++ b/core/localizer_test.go @@ -1,7 +1,6 @@ package core import ( - "io/ioutil" "math/rand" "net/http" "net/http/httptest" @@ -40,7 +39,7 @@ func createTestLangFiles(t *testing.T) { } if _, err := os.Stat(fileName); err != nil && os.IsNotExist(err) { - err = ioutil.WriteFile(fileName, data, os.ModePerm) + err = os.WriteFile(fileName, data, os.ModePerm) require.Nil(t, err) } } @@ -103,7 +102,6 @@ func (l *LocalizerTest) Test_LocalizationMiddleware_Context() { func (l *LocalizerTest) Test_LocalizationMiddleware_Httptest() { var wg sync.WaitGroup - rand.Seed(time.Now().UnixNano()) l.localizer.Preload(DefaultLanguages) langMsgMap := map[language.Tag]string{ language.English: "Test message", @@ -120,9 +118,11 @@ func (l *LocalizerTest) Test_LocalizationMiddleware_Httptest() { for i := 0; i < 1000; i++ { wg.Add(1) + i := i go func(m map[language.Tag]string, wg *sync.WaitGroup) { + r := rand.New(rand.NewSource(time.Now().UnixNano() + int64(i))) // nolint:gosec var tag language.Tag - switch rand.Intn(3-1) + 1 { // nolint:gosec + switch r.Intn(3-1) + 1 { // nolint:gosec case 1: tag = language.English case 2: diff --git a/core/logger/account_logger_decorator.go b/core/logger/account_logger_decorator.go deleted file mode 100644 index e35d8e9..0000000 --- a/core/logger/account_logger_decorator.go +++ /dev/null @@ -1,96 +0,0 @@ -package logger - -import ( - "fmt" - - "github.com/op/go-logging" -) - -// DefaultAccountLoggerFormat contains default prefix format for the AccountLoggerDecorator. -// Its messages will look like this (assuming you will provide the connection URL and account name): -// messageHandler (https://any.simla.com => @tg_account): sent message with id=1 -const DefaultAccountLoggerFormat = "%s (%s => %s):" - -type ComponentAware interface { - SetComponent(string) -} - -type ConnectionAware interface { - SetConnectionIdentifier(string) -} - -type AccountAware interface { - SetAccountIdentifier(string) -} - -type PrefixFormatAware interface { - SetPrefixFormat(string) -} - -type AccountLogger interface { - PrefixedLogger - ComponentAware - ConnectionAware - AccountAware - PrefixFormatAware -} - -type AccountLoggerDecorator struct { - format string - component string - connIdentifier string - accIdentifier string - PrefixDecorator -} - -func DecorateForAccount(base Logger, component, connIdentifier, accIdentifier string) AccountLogger { - return (&AccountLoggerDecorator{ - PrefixDecorator: PrefixDecorator{ - backend: base, - }, - component: component, - connIdentifier: connIdentifier, - accIdentifier: accIdentifier, - }).updatePrefix() -} - -// NewForAccount returns logger for account. It uses StandardLogger under the hood. -func NewForAccount( - transportCode, component, connIdentifier, accIdentifier string, - logLevel logging.Level, - logFormat logging.Formatter) AccountLogger { - return DecorateForAccount(NewStandard(transportCode, logLevel, logFormat), - component, connIdentifier, accIdentifier) -} - -func (a *AccountLoggerDecorator) SetComponent(s string) { - a.component = s - a.updatePrefix() -} - -func (a *AccountLoggerDecorator) SetConnectionIdentifier(s string) { - a.connIdentifier = s - a.updatePrefix() -} - -func (a *AccountLoggerDecorator) SetAccountIdentifier(s string) { - a.accIdentifier = s - a.updatePrefix() -} - -func (a *AccountLoggerDecorator) SetPrefixFormat(s string) { - a.format = s - a.updatePrefix() -} - -func (a *AccountLoggerDecorator) updatePrefix() AccountLogger { - a.SetPrefix(fmt.Sprintf(a.prefixFormat(), a.component, a.connIdentifier, a.accIdentifier)) - return a -} - -func (a *AccountLoggerDecorator) prefixFormat() string { - if a.format == "" { - return DefaultAccountLoggerFormat - } - return a.format -} diff --git a/core/logger/account_logger_decorator_test.go b/core/logger/account_logger_decorator_test.go deleted file mode 100644 index ab6a8b9..0000000 --- a/core/logger/account_logger_decorator_test.go +++ /dev/null @@ -1,98 +0,0 @@ -package logger - -import ( - "bytes" - "fmt" - "testing" - - "github.com/op/go-logging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -const ( - testComponent = "ComponentName" - testConnectionID = "https://test.retailcrm.pro" - testAccountID = "@account_name" -) - -type AccountLoggerDecoratorTest struct { - suite.Suite - buf *bytes.Buffer - logger AccountLogger -} - -func TestAccountLoggerDecorator(t *testing.T) { - suite.Run(t, new(AccountLoggerDecoratorTest)) -} - -func TestNewForAccount(t *testing.T) { - buf := &bytes.Buffer{} - logger := NewForAccount("code", "component", "conn", "acc", logging.DEBUG, DefaultLogFormatter()) - logger.(*AccountLoggerDecorator).backend.(*StandardLogger). - SetBaseLogger(NewBase(buf, "code", logging.DEBUG, DefaultLogFormatter())) - logger.Debugf("message %s", "text") - - assert.Contains(t, buf.String(), fmt.Sprintf(DefaultAccountLoggerFormat+" message", "component", "conn", "acc")) -} - -func (t *AccountLoggerDecoratorTest) SetupSuite() { - t.buf = &bytes.Buffer{} - t.logger = DecorateForAccount((&StandardLogger{}). - SetBaseLogger(NewBase(t.buf, "code", logging.DEBUG, DefaultLogFormatter())), - testComponent, testConnectionID, testAccountID) -} - -func (t *AccountLoggerDecoratorTest) SetupTest() { - t.buf.Reset() - t.logger.SetComponent(testComponent) - t.logger.SetConnectionIdentifier(testConnectionID) - t.logger.SetAccountIdentifier(testAccountID) - t.logger.SetPrefixFormat(DefaultAccountLoggerFormat) -} - -func (t *AccountLoggerDecoratorTest) Test_LogWithNewFormat() { - t.logger.SetPrefixFormat("[%s (%s: %s)] =>") - t.logger.Infof("test message") - - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), - fmt.Sprintf("[%s (%s: %s)] =>", testComponent, testConnectionID, testAccountID)) -} - -func (t *AccountLoggerDecoratorTest) Test_Log() { - t.logger.Infof("test message") - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), - fmt.Sprintf(DefaultAccountLoggerFormat, testComponent, testConnectionID, testAccountID)) -} - -func (t *AccountLoggerDecoratorTest) Test_SetComponent() { - t.logger.SetComponent("NewComponent") - t.logger.Infof("test message") - - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), - fmt.Sprintf(DefaultAccountLoggerFormat, "NewComponent", testConnectionID, testAccountID)) -} - -func (t *AccountLoggerDecoratorTest) Test_SetConnectionIdentifier() { - t.logger.SetComponent("NewComponent") - t.logger.SetConnectionIdentifier("https://test.simla.com") - t.logger.Infof("test message") - - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), - fmt.Sprintf(DefaultAccountLoggerFormat, "NewComponent", "https://test.simla.com", testAccountID)) -} - -func (t *AccountLoggerDecoratorTest) Test_SetAccountIdentifier() { - t.logger.SetComponent("NewComponent") - t.logger.SetConnectionIdentifier("https://test.simla.com") - t.logger.SetAccountIdentifier("@new_account_name") - t.logger.Infof("test message") - - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), - fmt.Sprintf(DefaultAccountLoggerFormat, "NewComponent", "https://test.simla.com", "@new_account_name")) -} diff --git a/core/logger/api_client_adapter.go b/core/logger/api_client_adapter.go new file mode 100644 index 0000000..8d88117 --- /dev/null +++ b/core/logger/api_client_adapter.go @@ -0,0 +1,42 @@ +package logger + +import ( + "fmt" + + "go.uber.org/zap" + + retailcrm "github.com/retailcrm/api-client-go/v2" +) + +const ( + apiDebugLogReq = "API Request: %s %s" + apiDebugLogResp = "API Response: %s" +) + +type apiClientAdapter struct { + logger Logger +} + +// APIClientAdapter returns BasicLogger that calls underlying logger. +func APIClientAdapter(logger Logger) retailcrm.BasicLogger { + return &apiClientAdapter{logger: logger} +} + +// Printf data in the log using Debug method. +func (l *apiClientAdapter) Printf(format string, v ...interface{}) { + switch format { + case apiDebugLogReq: + var url, key string + if len(v) > 0 { + url = fmt.Sprint(v[0]) + } + if len(v) > 1 { + key = fmt.Sprint(v[1]) + } + l.logger.Debug("API Request", zap.String("url", url), zap.String("key", key)) + case apiDebugLogResp: + l.logger.Debug("API Response", Body(v[0])) + default: + l.logger.Debug(fmt.Sprintf(format, v...)) + } +} diff --git a/core/logger/api_client_adapter_test.go b/core/logger/api_client_adapter_test.go new file mode 100644 index 0000000..4a4d6bc --- /dev/null +++ b/core/logger/api_client_adapter_test.go @@ -0,0 +1,41 @@ +package logger + +import ( + "net/http" + "testing" + + "github.com/h2non/gock" + retailcrm "github.com/retailcrm/api-client-go/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAPIClientAdapter(t *testing.T) { + log := newJSONBufferedLogger(nil) + client := retailcrm.New("https://example.com", "test_key").WithLogger(APIClientAdapter(log.Logger())) + client.Debug = true + + defer gock.Off() + gock.New("https://example.com"). + Get("/api/credentials"). + Reply(http.StatusOK). + JSON(retailcrm.CredentialResponse{Success: true}) + + _, _, err := client.APICredentials() + require.NoError(t, err) + + entries, err := log.ScanAll() + require.NoError(t, err) + require.Len(t, entries, 2) + + assert.Equal(t, "DEBUG", entries[0].LevelName) + assert.True(t, entries[0].DateTime.Valid) + assert.Equal(t, "API Request", entries[0].Message) + assert.Equal(t, "test_key", entries[0].Context["key"]) + assert.Equal(t, "https://example.com/api/credentials", entries[0].Context["url"]) + + assert.Equal(t, "DEBUG", entries[1].LevelName) + assert.True(t, entries[1].DateTime.Valid) + assert.Equal(t, "API Response", entries[1].Message) + assert.Equal(t, map[string]interface{}{"success": true}, entries[1].Context["body"]) +} diff --git a/core/logger/attrs.go b/core/logger/attrs.go new file mode 100644 index 0000000..06ec6e0 --- /dev/null +++ b/core/logger/attrs.go @@ -0,0 +1,94 @@ +package logger + +import ( + "fmt" + "io" + "net/http" + + json "github.com/goccy/go-json" + + "go.uber.org/zap" +) + +// HandlerAttr represents the attribute name for the handler. +const HandlerAttr = "handler" + +// ConnectionAttr represents the attribute name for the connection. +const ConnectionAttr = "connection" + +// AccountAttr represents the attribute name for the account. +const AccountAttr = "account" + +// CounterIDAttr represents the attribute name for the counter ID. +const CounterIDAttr = "counterId" + +// ErrorAttr represents the attribute name for an error. +const ErrorAttr = "error" + +// FailureMessageAttr represents the attribute name for a failure message. +const FailureMessageAttr = "failureMessage" + +// BodyAttr represents the attribute name for the request body. +const BodyAttr = "body" + +// HTTPMethodAttr represents the attribute name for the HTTP method. +const HTTPMethodAttr = "method" + +// HTTPStatusAttr represents the attribute name for the HTTP status code. +const HTTPStatusAttr = "statusCode" + +// HTTPStatusNameAttr represents the attribute name for the HTTP status name. +const HTTPStatusNameAttr = "statusName" + +// Err returns a zap.Field with the given error value. +func Err(err any) zap.Field { + if err == nil { + return zap.String(ErrorAttr, "") + } + return zap.Any(ErrorAttr, err) +} + +// Handler returns a zap.Field with the given handler name. +func Handler(name string) zap.Field { + return zap.String(HandlerAttr, name) +} + +// HTTPStatusCode returns a zap.Field with the given HTTP status code. +func HTTPStatusCode(code int) zap.Field { + return zap.Int(HTTPStatusAttr, code) +} + +// HTTPStatusName returns a zap.Field with the given HTTP status name. +func HTTPStatusName(code int) zap.Field { + return zap.String(HTTPStatusNameAttr, http.StatusText(code)) +} + +// Body returns a zap.Field with the given request body value. +func Body(val any) zap.Field { + switch item := val.(type) { + case string: + var m map[string]interface{} + if err := json.Unmarshal([]byte(item), &m); err == nil { + return zap.Any(BodyAttr, m) + } + return zap.String(BodyAttr, item) + case []byte: + var m interface{} + if err := json.Unmarshal(item, &m); err == nil { + return zap.Any(BodyAttr, m) + } + return zap.String(BodyAttr, string(item)) + case io.Reader: + data, err := io.ReadAll(item) + if err != nil { + return zap.String(BodyAttr, fmt.Sprintf("%#v", val)) + } + var m interface{} + if err := json.Unmarshal(data, &m); err == nil { + return zap.Any(BodyAttr, m) + } + return zap.String(BodyAttr, string(data)) + default: + return zap.String(BodyAttr, fmt.Sprintf("%#v", val)) + } +} diff --git a/core/logger/attrs_test.go b/core/logger/attrs_test.go new file mode 100644 index 0000000..2bb519d --- /dev/null +++ b/core/logger/attrs_test.go @@ -0,0 +1,156 @@ +package logger + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestErr(t *testing.T) { + var cases = []struct { + source interface{} + expected string + }{ + { + source: nil, + expected: "", + }, + { + source: errors.New("untimely error"), + expected: "untimely error", + }, + } + + for _, c := range cases { + val := Err(c.source) + assert.Equal(t, c.expected, func() string { + if val.String != "" { + return val.String + } + if val.Interface != nil { + return fmt.Sprintf("%s", val.Interface) + } + return "" + }()) + assert.Equal(t, ErrorAttr, val.Key) + } +} + +func TestHandler(t *testing.T) { + val := Handler("handlerName") + assert.Equal(t, HandlerAttr, val.Key) + assert.Equal(t, "handlerName", val.String) +} + +func TestHTTPStatusCode(t *testing.T) { + val := HTTPStatusCode(http.StatusOK) + assert.Equal(t, HTTPStatusAttr, val.Key) + assert.Equal(t, http.StatusOK, int(val.Integer)) +} + +func TestHTTPStatusName(t *testing.T) { + val := HTTPStatusName(http.StatusOK) + assert.Equal(t, HTTPStatusNameAttr, val.Key) + assert.Equal(t, http.StatusText(http.StatusOK), val.String) +} + +func TestBody(t *testing.T) { + var cases = []struct { + input interface{} + result interface{} + }{ + { + input: "", + result: nil, + }, + { + input: nil, + result: nil, + }, + { + input: "ooga booga", + result: "ooga booga", + }, + { + input: `{"success":true}`, + result: map[string]interface{}{"success": true}, + }, + { + input: []byte{}, + result: nil, + }, + { + input: nil, + result: nil, + }, + { + input: []byte("ooga booga"), + result: "ooga booga", + }, + { + input: []byte(`{"success":true}`), + result: map[string]interface{}{"success": true}, + }, + { + input: newReaderMock(func(p []byte) (n int, err error) { + return 0, io.EOF + }), + result: nil, + }, + { + input: newReaderMockData([]byte{}), + result: nil, + }, + { + input: newReaderMockData([]byte("ooga booga")), + result: "ooga booga", + }, + + { + input: newReaderMockData([]byte(`{"success":true}`)), + result: map[string]interface{}{"success": true}, + }, + } + for _, c := range cases { + val := Body(c.input) + assert.Equal(t, BodyAttr, val.Key) + + switch assertion := c.result.(type) { + case string: + assert.Equal(t, assertion, val.String) + case int: + assert.Equal(t, assertion, int(val.Integer)) + default: + assert.Equal(t, c.result, val.Interface) + } + } +} + +type readerMock struct { + mock.Mock +} + +func newReaderMock(cb func(p []byte) (n int, err error)) io.Reader { + r := &readerMock{} + r.On("Read", mock.Anything).Return(cb) + return r +} + +func newReaderMockData(data []byte) io.Reader { + return newReaderMock(bytes.NewReader(data).Read) +} + +func (m *readerMock) Read(p []byte) (n int, err error) { + args := m.Called(p) + out := args.Get(0) + if cb, ok := out.(func(p []byte) (n int, err error)); ok { + return cb(p) + } + return args.Int(0), args.Error(1) +} diff --git a/core/logger/buffer_logger_test.go b/core/logger/buffer_logger_test.go new file mode 100644 index 0000000..0c6115e --- /dev/null +++ b/core/logger/buffer_logger_test.go @@ -0,0 +1,266 @@ +package logger + +import ( + "bufio" + "bytes" + "encoding/json" + "io" + "os" + "sync" + + "github.com/guregu/null/v5" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +type logRecord struct { + LevelName string `json:"level_name"` + DateTime null.Time `json:"datetime"` + Caller string `json:"caller"` + Message string `json:"message"` + Handler string `json:"handler,omitempty"` + Connection string `json:"connection,omitempty"` + Account string `json:"account,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type jSONRecordScanner struct { + scan *bufio.Scanner + buf *bufferLogger +} + +func newJSONBufferedLogger(buf *bufferLogger) *jSONRecordScanner { + if buf == nil { + buf = newBufferLogger() + } + return &jSONRecordScanner{scan: bufio.NewScanner(buf), buf: buf} +} + +func (s *jSONRecordScanner) ScanAll() ([]logRecord, error) { + var entries []logRecord + for s.scan.Scan() { + entry := logRecord{} + if err := json.Unmarshal(s.scan.Bytes(), &entry); err != nil { + return entries, err + } + entries = append(entries, entry) + } + return entries, nil +} + +func (s *jSONRecordScanner) Logger() Logger { + return s.buf +} + +// bufferLogger is an implementation of the BufferedLogger. +type bufferLogger struct { + Default + buf lockableBuffer +} + +// NewBufferedLogger returns new BufferedLogger instance. +func newBufferLogger() *bufferLogger { + bl := &bufferLogger{} + bl.Logger = zap.New( + zapcore.NewCore( + NewJSONWithContextEncoder( + EncoderConfigJSON()), zap.CombineWriteSyncers(os.Stdout, os.Stderr, &bl.buf), zapcore.DebugLevel)) + return bl +} + +func (l *bufferLogger) With(fields ...zapcore.Field) Logger { + return &bufferLogger{ + Default: Default{ + Logger: l.Logger.With(fields...), + }, + } +} + +func (l *bufferLogger) WithLazy(fields ...zapcore.Field) Logger { + return &bufferLogger{ + Default: Default{ + Logger: l.Logger.WithLazy(fields...), + }, + } +} + +func (l *bufferLogger) ForHandler(handler any) Logger { + return l.WithLazy(zap.Any(HandlerAttr, handler)) +} + +func (l *bufferLogger) ForConnection(conn any) Logger { + return l.WithLazy(zap.Any(ConnectionAttr, conn)) +} + +func (l *bufferLogger) ForAccount(acc any) Logger { + return l.WithLazy(zap.Any(AccountAttr, acc)) +} + +// Read bytes from the logger buffer. io.Reader implementation. +func (l *bufferLogger) Read(p []byte) (n int, err error) { + return l.buf.Read(p) +} + +// String contents of the logger buffer. fmt.Stringer implementation. +func (l *bufferLogger) String() string { + return l.buf.String() +} + +// Bytes is a shorthand for the underlying bytes.Buffer method. Returns byte slice with the buffer contents. +func (l *bufferLogger) Bytes() []byte { + return l.buf.Bytes() +} + +// Reset is a shorthand for the underlying bytes.Buffer method. It will reset buffer contents. +func (l *bufferLogger) Reset() { + l.buf.Reset() +} + +type lockableBuffer struct { + buf bytes.Buffer + rw sync.RWMutex +} + +func (b *lockableBuffer) Bytes() []byte { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Bytes() +} + +func (b *lockableBuffer) AvailableBuffer() []byte { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.AvailableBuffer() +} + +func (b *lockableBuffer) String() string { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.String() +} + +func (b *lockableBuffer) Len() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Len() +} + +func (b *lockableBuffer) Cap() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Cap() +} + +func (b *lockableBuffer) Available() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Available() +} + +func (b *lockableBuffer) Truncate(n int) { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Truncate(n) +} + +func (b *lockableBuffer) Reset() { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Reset() +} + +func (b *lockableBuffer) Grow(n int) { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Grow(n) +} + +func (b *lockableBuffer) Write(p []byte) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Write(p) +} + +func (b *lockableBuffer) WriteString(s string) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteString(s) +} + +func (b *lockableBuffer) ReadFrom(r io.Reader) (n int64, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadFrom(r) +} + +func (b *lockableBuffer) WriteTo(w io.Writer) (n int64, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteTo(w) +} + +func (b *lockableBuffer) WriteByte(c byte) error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteByte(c) +} + +func (b *lockableBuffer) WriteRune(r rune) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteRune(r) +} + +func (b *lockableBuffer) Read(p []byte) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Read(p) +} + +func (b *lockableBuffer) Next(n int) []byte { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Next(n) +} + +func (b *lockableBuffer) ReadByte() (byte, error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadByte() +} + +func (b *lockableBuffer) ReadRune() (r rune, size int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadRune() +} + +func (b *lockableBuffer) UnreadRune() error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.UnreadRune() +} + +func (b *lockableBuffer) UnreadByte() error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.UnreadByte() +} + +func (b *lockableBuffer) ReadBytes(delim byte) (line []byte, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadBytes(delim) +} + +func (b *lockableBuffer) ReadString(delim byte) (line string, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadString(delim) +} + +// Sync is a no-op. +func (b *lockableBuffer) Sync() error { + return nil +} diff --git a/core/logger/bufferpool.go b/core/logger/bufferpool.go new file mode 100644 index 0000000..e0a4e8b --- /dev/null +++ b/core/logger/bufferpool.go @@ -0,0 +1,29 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// 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. + +package logger + +import "go.uber.org/zap/buffer" + +var ( + _pool = buffer.NewPool() + // GetBufferPool retrieves a buffer from the pool, creating one if necessary. + GetBufferPool = _pool.Get +) diff --git a/core/logger/default.go b/core/logger/default.go new file mode 100644 index 0000000..017b4b5 --- /dev/null +++ b/core/logger/default.go @@ -0,0 +1,100 @@ +package logger + +import ( + "strconv" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Logger is a logging interface. +type Logger interface { + // With adds fields to the logger and returns a new logger with those fields. + With(fields ...zap.Field) Logger + // WithLazy adds fields to the logger lazily and returns a new logger with those fields. + WithLazy(fields ...zap.Field) Logger + // Level returns the logging level of the logger. + Level() zapcore.Level + // Check checks if the log message meets the given level. + Check(lvl zapcore.Level, msg string) *zapcore.CheckedEntry + // Log logs a message with the given level and fields. + Log(lvl zapcore.Level, msg string, fields ...zap.Field) + // Debug logs a debug-level message with the given fields. + Debug(msg string, fields ...zap.Field) + // Info logs an info-level message with the given fields. + Info(msg string, fields ...zap.Field) + // Warn logs a warning-level message with the given fields. + Warn(msg string, fields ...zap.Field) + // Error logs an error-level message with the given fields. + Error(msg string, fields ...zap.Field) + // DPanic logs a debug-panic-level message with the given fields and panics + // if the logger's panic level is set to a non-zero value. + DPanic(msg string, fields ...zap.Field) + // Panic logs a panic-level message with the given fields and panics immediately. + Panic(msg string, fields ...zap.Field) + // Fatal logs a fatal-level message with the given fields, then calls os.Exit(1). + Fatal(msg string, fields ...zap.Field) + // ForHandler returns a new logger that is associated with the given handler. + ForHandler(handler any) Logger + // ForConnection returns a new logger that is associated with the given connection. + ForConnection(conn any) Logger + // ForAccount returns a new logger that is associated with the given account. + ForAccount(acc any) Logger + // Sync returns an error if there's a problem writing log messages to disk, or nil if all writes were successful. + Sync() error +} + +// Default is a default logger implementation. +type Default struct { + *zap.Logger +} + +// NewDefault creates a new default logger with the given format and debug level. +func NewDefault(format string, debug bool) Logger { + return &Default{ + Logger: NewZap(format, debug), + } +} + +// With adds fields to the logger and returns a new logger with those fields. +func (l *Default) With(fields ...zap.Field) Logger { + return l.clone(l.Logger.With(fields...)) +} + +// WithLazy adds fields to the logger lazily and returns a new logger with those fields. +func (l *Default) WithLazy(fields ...zap.Field) Logger { + return l.clone(l.Logger.WithLazy(fields...)) +} + +// ForHandler returns a new logger that is associated with the given handler. +func (l *Default) ForHandler(handler any) Logger { + return l.WithLazy(zap.Any(HandlerAttr, handler)) +} + +// ForConnection returns a new logger that is associated with the given connection. +func (l *Default) ForConnection(conn any) Logger { + return l.WithLazy(zap.Any(ConnectionAttr, conn)) +} + +// ForAccount returns a new logger that is associated with the given account. +func (l *Default) ForAccount(acc any) Logger { + return l.WithLazy(zap.Any(AccountAttr, acc)) +} + +// clone creates a copy of the given logger. +func (l *Default) clone(log *zap.Logger) Logger { + return &Default{Logger: log} +} + +// AnyZapFields converts an array of values to zap fields. +func AnyZapFields(args []interface{}) []zap.Field { + fields := make([]zap.Field, len(args)) + for i := 0; i < len(fields); i++ { + if val, ok := args[i].(zap.Field); ok { + fields[i] = val + continue + } + fields[i] = zap.Any("arg"+strconv.Itoa(i), args[i]) + } + return fields +} diff --git a/core/logger/default_test.go b/core/logger/default_test.go new file mode 100644 index 0000000..7301568 --- /dev/null +++ b/core/logger/default_test.go @@ -0,0 +1,90 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "go.uber.org/zap" +) + +type TestDefaultSuite struct { + suite.Suite +} + +func TestDefault(t *testing.T) { + suite.Run(t, new(TestDefaultSuite)) +} + +func (s *TestDefaultSuite) TestNewDefault_OK() { + jsonLog := NewDefault("json", false) + consoleLog := NewDefault("console", true) + + s.Assert().NotNil(jsonLog) + s.Assert().NotNil(consoleLog) +} + +func (s *TestDefaultSuite) TestNewDefault_Panic() { + s.Assert().PanicsWithValue("unknown logger format: rar", func() { + NewDefault("rar", false) + }) +} + +func (s *TestDefaultSuite) TestWith() { + log := newBufferLogger() + log.With(zap.String(HandlerAttr, "Handler")).Info("test") + items, err := newJSONBufferedLogger(log).ScanAll() + + s.Require().NoError(err) + s.Require().Len(items, 1) + s.Assert().Equal("Handler", items[0].Handler) +} + +func (s *TestDefaultSuite) TestWithLazy() { + log := newBufferLogger() + log.WithLazy(zap.String(HandlerAttr, "Handler")).Info("test") + items, err := newJSONBufferedLogger(log).ScanAll() + + s.Require().NoError(err) + s.Require().Len(items, 1) + s.Assert().Equal("Handler", items[0].Handler) +} + +func (s *TestDefaultSuite) TestForHandler() { + log := newBufferLogger() + log.ForHandler("Handler").Info("test") + items, err := newJSONBufferedLogger(log).ScanAll() + + s.Require().NoError(err) + s.Require().Len(items, 1) + s.Assert().Equal("Handler", items[0].Handler) +} + +func (s *TestDefaultSuite) TestForConnection() { + log := newBufferLogger() + log.ForConnection("connection").Info("test") + items, err := newJSONBufferedLogger(log).ScanAll() + + s.Require().NoError(err) + s.Require().Len(items, 1) + s.Assert().Equal("connection", items[0].Connection) +} + +func (s *TestDefaultSuite) TestForAccount() { + log := newBufferLogger() + log.ForAccount("account").Info("test") + items, err := newJSONBufferedLogger(log).ScanAll() + + s.Require().NoError(err) + s.Require().Len(items, 1) + s.Assert().Equal("account", items[0].Account) +} + +func TestAnyZapFields(t *testing.T) { + fields := AnyZapFields([]interface{}{zap.String("k0", "v0"), "ooga", "booga"}) + require.Len(t, fields, 3) + assert.Equal(t, zap.String("k0", "v0"), fields[0]) + assert.Equal(t, zap.String("arg1", "ooga"), fields[1]) + assert.Equal(t, zap.String("arg2", "booga"), fields[2]) +} diff --git a/core/logger/gin.go b/core/logger/gin.go new file mode 100644 index 0000000..5ecf3b2 --- /dev/null +++ b/core/logger/gin.go @@ -0,0 +1,37 @@ +package logger + +import ( + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +// GinMiddleware will construct Gin middleware which will log requests. +func GinMiddleware(log Logger) gin.HandlerFunc { + return func(c *gin.Context) { + // Start timer + start := time.Now() + path := c.Request.URL.Path + raw := c.Request.URL.RawQuery + + // Process request + c.Next() + + end := time.Now() + if raw != "" { + path = path + "?" + raw + } + + log.Info("request", + zap.String(HandlerAttr, "GIN"), + zap.String("startTime", start.Format(time.RFC3339)), + zap.String("endTime", end.Format(time.RFC3339)), + zap.Any("latency", end.Sub(start)/time.Millisecond), + zap.String("remoteAddress", c.ClientIP()), + zap.String(HTTPMethodAttr, c.Request.Method), + zap.String("path", path), + zap.Int("bodySize", c.Writer.Size()), + ) + } +} diff --git a/core/logger/gin_test.go b/core/logger/gin_test.go new file mode 100644 index 0000000..3cf6cf7 --- /dev/null +++ b/core/logger/gin_test.go @@ -0,0 +1,38 @@ +package logger + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGinMiddleware(t *testing.T) { + log := newBufferLogger() + rr := httptest.NewRecorder() + r := gin.New() + r.Use(GinMiddleware(log)) + r.GET("/mine", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{}) + }) + r.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/mine", nil)) + + require.Equal(t, http.StatusOK, rr.Code) + items, err := newJSONBufferedLogger(log).ScanAll() + require.NoError(t, err) + require.Len(t, items, 1) + require.NotEmpty(t, items[0].Context) + assert.NotEmpty(t, items[0].Context["startTime"]) + assert.NotEmpty(t, items[0].Context["endTime"]) + assert.True(t, func() bool { + _, ok := items[0].Context["latency"] + return ok + }()) + assert.NotEmpty(t, items[0].Context["remoteAddress"]) + assert.NotEmpty(t, items[0].Context[HTTPMethodAttr]) + assert.NotEmpty(t, items[0].Context["path"]) + assert.NotEmpty(t, items[0].Context["bodySize"]) +} diff --git a/core/logger/json_with_context_encoder.go b/core/logger/json_with_context_encoder.go new file mode 100644 index 0000000..cc3a8a1 --- /dev/null +++ b/core/logger/json_with_context_encoder.go @@ -0,0 +1,640 @@ +// Copyright (c) 2016 Uber Technologies, Inc. +// +// 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. + +// nolint +package logger + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io" + "math" + "time" + "unicode/utf8" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + + "go.uber.org/zap/buffer" +) + +// For JSON-escaping; see jsonWithContextEncoder.safeAddString below. +const _hex = "0123456789abcdef" + +var _jsonWithContextPool = NewPool(func() *jsonWithContextEncoder { + return &jsonWithContextEncoder{} +}) + +func init() { + err := zap.RegisterEncoder("json-with-context", func(config zapcore.EncoderConfig) (zapcore.Encoder, error) { + return NewJSONWithContextEncoder(config), nil + }) + if err != nil { + panic(err) + } +} + +func putJSONWithContextEncoder(enc *jsonWithContextEncoder) { + if enc.reflectBuf != nil { + enc.reflectBuf.Free() + } + enc.EncoderConfig = nil + enc.buf = nil + enc.spaced = false + enc.openNamespaces = 0 + enc.reflectBuf = nil + enc.reflectEnc = nil + _jsonWithContextPool.Put(enc) +} + +type jsonWithContextEncoder struct { + *zapcore.EncoderConfig + buf *buffer.Buffer + spaced bool // include spaces after colons and commas + openNamespaces int + + // for encoding generic values by reflection + reflectBuf *buffer.Buffer + reflectEnc zapcore.ReflectedEncoder +} + +// NewJSONWithContextEncoder creates a fast, low-allocation JSON encoder. The encoder +// appropriately escapes all field keys and values. +// +// Note that the encoder doesn't deduplicate keys, so it's possible to produce +// a message like +// +// {"foo":"bar","foo":"baz"} +// +// This is permitted by the JSON specification, but not encouraged. Many +// libraries will ignore duplicate key-value pairs (typically keeping the last +// pair) when unmarshaling, but users should attempt to avoid adding duplicate +// keys. +func NewJSONWithContextEncoder(cfg zapcore.EncoderConfig) zapcore.Encoder { + return newJSONWithContextEncoder(cfg, false) +} + +func newJSONWithContextEncoder(cfg zapcore.EncoderConfig, spaced bool) *jsonWithContextEncoder { + if cfg.SkipLineEnding { + cfg.LineEnding = "" + } else if cfg.LineEnding == "" { + cfg.LineEnding = zapcore.DefaultLineEnding + } + + // If no EncoderConfig.NewReflectedEncoder is provided by the user, then use default + if cfg.NewReflectedEncoder == nil { + cfg.NewReflectedEncoder = defaultReflectedEncoder + } + + return &jsonWithContextEncoder{ + EncoderConfig: &cfg, + buf: GetBufferPool(), + spaced: spaced, + } +} + +func defaultReflectedEncoder(w io.Writer) zapcore.ReflectedEncoder { + enc := json.NewEncoder(w) + // For consistency with our custom JSON encoder. + enc.SetEscapeHTML(false) + return enc +} + +func (enc *jsonWithContextEncoder) AddArray(key string, arr zapcore.ArrayMarshaler) error { + enc.addKey(key) + return enc.AppendArray(arr) +} + +func (enc *jsonWithContextEncoder) AddObject(key string, obj zapcore.ObjectMarshaler) error { + enc.addKey(key) + return enc.AppendObject(obj) +} + +func (enc *jsonWithContextEncoder) AddBinary(key string, val []byte) { + enc.AddString(key, base64.StdEncoding.EncodeToString(val)) +} + +func (enc *jsonWithContextEncoder) AddByteString(key string, val []byte) { + enc.addKey(key) + enc.AppendByteString(val) +} + +func (enc *jsonWithContextEncoder) AddBool(key string, val bool) { + enc.addKey(key) + enc.AppendBool(val) +} + +func (enc *jsonWithContextEncoder) AddComplex128(key string, val complex128) { + enc.addKey(key) + enc.AppendComplex128(val) +} + +func (enc *jsonWithContextEncoder) AddComplex64(key string, val complex64) { + enc.addKey(key) + enc.AppendComplex64(val) +} + +func (enc *jsonWithContextEncoder) AddDuration(key string, val time.Duration) { + enc.addKey(key) + enc.AppendDuration(val) +} + +func (enc *jsonWithContextEncoder) AddFloat64(key string, val float64) { + enc.addKey(key) + enc.AppendFloat64(val) +} + +func (enc *jsonWithContextEncoder) AddFloat32(key string, val float32) { + enc.addKey(key) + enc.AppendFloat32(val) +} + +func (enc *jsonWithContextEncoder) AddInt64(key string, val int64) { + enc.addKey(key) + enc.AppendInt64(val) +} + +func (enc *jsonWithContextEncoder) resetReflectBuf() { + if enc.reflectBuf == nil { + enc.reflectBuf = GetBufferPool() + enc.reflectEnc = enc.NewReflectedEncoder(enc.reflectBuf) + } else { + enc.reflectBuf.Reset() + } +} + +var nullLiteralBytes = []byte("null") + +// Only invoke the standard JSON encoder if there is actually something to +// encode; otherwise write JSON null literal directly. +func (enc *jsonWithContextEncoder) encodeReflected(obj interface{}) ([]byte, error) { + if obj == nil { + return nullLiteralBytes, nil + } + enc.resetReflectBuf() + if err := enc.reflectEnc.Encode(obj); err != nil { + return nil, err + } + enc.reflectBuf.TrimNewline() + return enc.reflectBuf.Bytes(), nil +} + +func (enc *jsonWithContextEncoder) AddReflected(key string, obj interface{}) error { + valueBytes, err := enc.encodeReflected(obj) + if err != nil { + return err + } + enc.addKey(key) + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonWithContextEncoder) OpenNamespace(key string) { + enc.addKey(key) + enc.buf.AppendByte('{') + enc.openNamespaces++ +} + +func (enc *jsonWithContextEncoder) AddString(key, val string) { + enc.addKey(key) + enc.AppendString(val) +} + +func (enc *jsonWithContextEncoder) AddTime(key string, val time.Time) { + enc.addKey(key) + enc.AppendTime(val) +} + +func (enc *jsonWithContextEncoder) AddUint64(key string, val uint64) { + enc.addKey(key) + enc.AppendUint64(val) +} + +func (enc *jsonWithContextEncoder) AppendArray(arr zapcore.ArrayMarshaler) error { + enc.addElementSeparator() + enc.buf.AppendByte('[') + err := arr.MarshalLogArray(enc) + enc.buf.AppendByte(']') + return err +} + +func (enc *jsonWithContextEncoder) AppendObject(obj zapcore.ObjectMarshaler) error { + // Close ONLY new openNamespaces that are created during + // AppendObject(). + old := enc.openNamespaces + enc.openNamespaces = 0 + enc.addElementSeparator() + enc.buf.AppendByte('{') + err := obj.MarshalLogObject(enc) + enc.buf.AppendByte('}') + enc.closeOpenNamespaces() + enc.openNamespaces = old + return err +} + +func (enc *jsonWithContextEncoder) AppendBool(val bool) { + enc.addElementSeparator() + enc.buf.AppendBool(val) +} + +func (enc *jsonWithContextEncoder) AppendByteString(val []byte) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddByteString(val) + enc.buf.AppendByte('"') +} + +// appendComplex appends the encoded form of the provided complex128 value. +// precision specifies the encoding precision for the real and imaginary +// components of the complex number. +func (enc *jsonWithContextEncoder) appendComplex(val complex128, precision int) { + enc.addElementSeparator() + // Cast to a platform-independent, fixed-size type. + r, i := float64(real(val)), float64(imag(val)) + enc.buf.AppendByte('"') + // Because we're always in a quoted string, we can use strconv without + // special-casing NaN and +/-Inf. + enc.buf.AppendFloat(r, precision) + // If imaginary part is less than 0, minus (-) sign is added by default + // by AppendFloat. + if i >= 0 { + enc.buf.AppendByte('+') + } + enc.buf.AppendFloat(i, precision) + enc.buf.AppendByte('i') + enc.buf.AppendByte('"') +} + +func (enc *jsonWithContextEncoder) AppendDuration(val time.Duration) { + cur := enc.buf.Len() + if e := enc.EncodeDuration; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeDuration is a no-op. Fall back to nanoseconds to keep + // JSON valid. + enc.AppendInt64(int64(val)) + } +} + +func (enc *jsonWithContextEncoder) AppendInt64(val int64) { + enc.addElementSeparator() + enc.buf.AppendInt(val) +} + +func (enc *jsonWithContextEncoder) AppendReflected(val interface{}) error { + valueBytes, err := enc.encodeReflected(val) + if err != nil { + return err + } + enc.addElementSeparator() + _, err = enc.buf.Write(valueBytes) + return err +} + +func (enc *jsonWithContextEncoder) AppendString(val string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(val) + enc.buf.AppendByte('"') +} + +func (enc *jsonWithContextEncoder) AppendTimeLayout(time time.Time, layout string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.buf.AppendTime(time, layout) + enc.buf.AppendByte('"') +} + +func (enc *jsonWithContextEncoder) AppendTime(val time.Time) { + cur := enc.buf.Len() + if e := enc.EncodeTime; e != nil { + e(val, enc) + } + if cur == enc.buf.Len() { + // User-supplied EncodeTime is a no-op. Fall back to nanos since epoch to keep + // output JSON valid. + enc.AppendInt64(val.UnixNano()) + } +} + +func (enc *jsonWithContextEncoder) AppendUint64(val uint64) { + enc.addElementSeparator() + enc.buf.AppendUint(val) +} + +func (enc *jsonWithContextEncoder) AddInt(k string, v int) { enc.AddInt64(k, int64(v)) } +func (enc *jsonWithContextEncoder) AddInt32(k string, v int32) { enc.AddInt64(k, int64(v)) } +func (enc *jsonWithContextEncoder) AddInt16(k string, v int16) { enc.AddInt64(k, int64(v)) } +func (enc *jsonWithContextEncoder) AddInt8(k string, v int8) { enc.AddInt64(k, int64(v)) } +func (enc *jsonWithContextEncoder) AddUint(k string, v uint) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonWithContextEncoder) AddUint32(k string, v uint32) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonWithContextEncoder) AddUint16(k string, v uint16) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonWithContextEncoder) AddUint8(k string, v uint8) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonWithContextEncoder) AddUintptr(k string, v uintptr) { enc.AddUint64(k, uint64(v)) } +func (enc *jsonWithContextEncoder) AppendComplex64(v complex64) { enc.appendComplex(complex128(v), 32) } +func (enc *jsonWithContextEncoder) AppendComplex128(v complex128) { + enc.appendComplex(complex128(v), 64) +} +func (enc *jsonWithContextEncoder) AppendFloat64(v float64) { enc.appendFloat(v, 64) } +func (enc *jsonWithContextEncoder) AppendFloat32(v float32) { enc.appendFloat(float64(v), 32) } +func (enc *jsonWithContextEncoder) AppendInt(v int) { enc.AppendInt64(int64(v)) } +func (enc *jsonWithContextEncoder) AppendInt32(v int32) { enc.AppendInt64(int64(v)) } +func (enc *jsonWithContextEncoder) AppendInt16(v int16) { enc.AppendInt64(int64(v)) } +func (enc *jsonWithContextEncoder) AppendInt8(v int8) { enc.AppendInt64(int64(v)) } +func (enc *jsonWithContextEncoder) AppendUint(v uint) { enc.AppendUint64(uint64(v)) } +func (enc *jsonWithContextEncoder) AppendUint32(v uint32) { enc.AppendUint64(uint64(v)) } +func (enc *jsonWithContextEncoder) AppendUint16(v uint16) { enc.AppendUint64(uint64(v)) } +func (enc *jsonWithContextEncoder) AppendUint8(v uint8) { enc.AppendUint64(uint64(v)) } +func (enc *jsonWithContextEncoder) AppendUintptr(v uintptr) { enc.AppendUint64(uint64(v)) } + +func (enc *jsonWithContextEncoder) Clone() zapcore.Encoder { + clone := enc.clone() + clone.buf.Write(enc.buf.Bytes()) + return clone +} + +func (enc *jsonWithContextEncoder) clone() *jsonWithContextEncoder { + clone := _jsonWithContextPool.Get() + clone.EncoderConfig = enc.EncoderConfig + clone.spaced = enc.spaced + clone.openNamespaces = enc.openNamespaces + clone.buf = GetBufferPool() + return clone +} + +func (enc *jsonWithContextEncoder) EncodeEntry(ent zapcore.Entry, fields []zapcore.Field) (*buffer.Buffer, error) { + final := enc.clone() + final.buf.AppendByte('{') + + if final.LevelKey != "" && final.EncodeLevel != nil { + final.addKey(final.LevelKey) + cur := final.buf.Len() + final.EncodeLevel(ent.Level, final) + if cur == final.buf.Len() { + // User-supplied EncodeLevel was a no-op. Fall back to strings to keep + // output JSON valid. + final.AppendString(ent.Level.String()) + } + } + if final.TimeKey != "" { + final.AddTime(final.TimeKey, ent.Time) + } + if ent.LoggerName != "" && final.NameKey != "" { + final.addKey(final.NameKey) + cur := final.buf.Len() + nameEncoder := final.EncodeName + + // if no name encoder provided, fall back to FullNameEncoder for backwards + // compatibility + if nameEncoder == nil { + nameEncoder = zapcore.FullNameEncoder + } + + nameEncoder(ent.LoggerName, final) + if cur == final.buf.Len() { + // User-supplied EncodeName was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.LoggerName) + } + } + if ent.Caller.Defined { + if final.CallerKey != "" { + final.addKey(final.CallerKey) + cur := final.buf.Len() + final.EncodeCaller(ent.Caller, final) + if cur == final.buf.Len() { + // User-supplied EncodeCaller was a no-op. Fall back to strings to + // keep output JSON valid. + final.AppendString(ent.Caller.String()) + } + } + if final.FunctionKey != "" { + final.addKey(final.FunctionKey) + final.AppendString(ent.Caller.Function) + } + } + if final.MessageKey != "" { + final.addKey(enc.MessageKey) + final.AppendString(ent.Message) + } + if enc.buf.Len() > 0 { + final.addElementSeparator() + final.buf.Write(enc.buf.Bytes()) + } + addFields(final, fields) + final.closeOpenNamespaces() + if ent.Stack != "" && final.StacktraceKey != "" { + final.AddString(final.StacktraceKey, ent.Stack) + } + final.buf.AppendByte('}') + final.buf.AppendString(final.LineEnding) + + ret := final.buf + putJSONWithContextEncoder(final) + return ret, nil +} + +func addFields(enc zapcore.ObjectEncoder, fields []zapcore.Field) { + m := make(map[string]interface{}) + hasEntries := false + for _, f := range fields { + switch f.Key { + case HandlerAttr, ConnectionAttr, AccountAttr: + f.AddTo(enc) + default: + hasEntries = true + if f.Interface != nil { + switch t := f.Interface.(type) { + case fmt.Stringer: + m[f.Key] = t.String() + case fmt.GoStringer: + m[f.Key] = t.GoString() + case error: + m[f.Key] = t.Error() + default: + m[f.Key] = f.Interface + } + continue + } + if f.String != "" { + m[f.Key] = f.String + continue + } + m[f.Key] = f.Integer + } + } + if hasEntries { + zap.Any("context", m).AddTo(enc) + } +} + +func (enc *jsonWithContextEncoder) truncate() { + enc.buf.Reset() +} + +func (enc *jsonWithContextEncoder) closeOpenNamespaces() { + for i := 0; i < enc.openNamespaces; i++ { + enc.buf.AppendByte('}') + } + enc.openNamespaces = 0 +} + +func (enc *jsonWithContextEncoder) addKey(key string) { + enc.addElementSeparator() + enc.buf.AppendByte('"') + enc.safeAddString(key) + enc.buf.AppendByte('"') + enc.buf.AppendByte(':') + if enc.spaced { + enc.buf.AppendByte(' ') + } +} + +func (enc *jsonWithContextEncoder) addElementSeparator() { + last := enc.buf.Len() - 1 + if last < 0 { + return + } + switch enc.buf.Bytes()[last] { + case '{', '[', ':', ',', ' ': + return + default: + enc.buf.AppendByte(',') + if enc.spaced { + enc.buf.AppendByte(' ') + } + } +} + +func (enc *jsonWithContextEncoder) appendFloat(val float64, bitSize int) { + enc.addElementSeparator() + switch { + case math.IsNaN(val): + enc.buf.AppendString(`"NaN"`) + case math.IsInf(val, 1): + enc.buf.AppendString(`"+Inf"`) + case math.IsInf(val, -1): + enc.buf.AppendString(`"-Inf"`) + default: + enc.buf.AppendFloat(val, bitSize) + } +} + +// safeAddString JSON-escapes a string and appends it to the internal buffer. +// Unlike the standard library's encoder, it doesn't attempt to protect the +// user from browser vulnerabilities or JSONP-related problems. +func (enc *jsonWithContextEncoder) safeAddString(s string) { + safeAppendStringLike( + (*buffer.Buffer).AppendString, + utf8.DecodeRuneInString, + enc.buf, + s, + ) +} + +// safeAddByteString is no-alloc equivalent of safeAddString(string(s)) for s []byte. +func (enc *jsonWithContextEncoder) safeAddByteString(s []byte) { + safeAppendStringLike( + (*buffer.Buffer).AppendBytes, + utf8.DecodeRune, + enc.buf, + s, + ) +} + +// safeAppendStringLike is a generic implementation of safeAddString and safeAddByteString. +// It appends a string or byte slice to the buffer, escaping all special characters. +func safeAppendStringLike[S []byte | string]( + // appendTo appends this string-like object to the buffer. + appendTo func(*buffer.Buffer, S), + // decodeRune decodes the next rune from the string-like object + // and returns its value and width in bytes. + decodeRune func(S) (rune, int), + buf *buffer.Buffer, + s S, +) { + // The encoding logic below works by skipping over characters + // that can be safely copied as-is, + // until a character is found that needs special handling. + // At that point, we copy everything we've seen so far, + // and then handle that special character. + // + // last is the index of the last byte that was copied to the buffer. + last := 0 + for i := 0; i < len(s); { + if s[i] >= utf8.RuneSelf { + // Character >= RuneSelf may be part of a multi-byte rune. + // They need to be decoded before we can decide how to handle them. + r, size := decodeRune(s[i:]) + if r != utf8.RuneError || size != 1 { + // No special handling required. + // Skip over this rune and continue. + i += size + continue + } + + // Invalid UTF-8 sequence. + // Replace it with the Unicode replacement character. + appendTo(buf, s[last:i]) + buf.AppendString(`\ufffd`) + + i++ + last = i + } else { + // Character < RuneSelf is a single-byte UTF-8 rune. + if s[i] >= 0x20 && s[i] != '\\' && s[i] != '"' { + // No escaping necessary. + // Skip over this character and continue. + i++ + continue + } + + // This character needs to be escaped. + appendTo(buf, s[last:i]) + switch s[i] { + case '\\', '"': + buf.AppendByte('\\') + buf.AppendByte(s[i]) + case '\n': + buf.AppendByte('\\') + buf.AppendByte('n') + case '\r': + buf.AppendByte('\\') + buf.AppendByte('r') + case '\t': + buf.AppendByte('\\') + buf.AppendByte('t') + default: + // Encode bytes < 0x20, except for the escape sequences above. + buf.AppendString(`\u00`) + buf.AppendByte(_hex[s[i]>>4]) + buf.AppendByte(_hex[s[i]&0xF]) + } + + i++ + last = i + } + } + + // add remaining + appendTo(buf, s[last:]) +} diff --git a/core/logger/logger.go b/core/logger/logger.go deleted file mode 100644 index 32445f9..0000000 --- a/core/logger/logger.go +++ /dev/null @@ -1,205 +0,0 @@ -package logger - -import ( - "io" - "os" - "sync" - - "github.com/op/go-logging" -) - -// Logger contains methods which should be present in logger implementation. -type Logger interface { - Fatal(args ...interface{}) - Fatalf(format string, args ...interface{}) - Panic(args ...interface{}) - Panicf(format string, args ...interface{}) - Critical(args ...interface{}) - Criticalf(format string, args ...interface{}) - Error(args ...interface{}) - Errorf(format string, args ...interface{}) - Warning(args ...interface{}) - Warningf(format string, args ...interface{}) - Notice(args ...interface{}) - Noticef(format string, args ...interface{}) - Info(args ...interface{}) - Infof(format string, args ...interface{}) - Debug(args ...interface{}) - Debugf(format string, args ...interface{}) -} - -// StandardLogger is a default implementation of Logger. Uses github.com/op/go-logging under the hood. -// This logger can prevent any write operations (disabled by default, use .Exclusive() method to enable). -type StandardLogger struct { - logger *logging.Logger - mutex *sync.RWMutex -} - -// NewStandard will create new StandardLogger with specified formatter. -// Usage: -// logger := NewLogger("telegram", logging.ERROR, DefaultLogFormatter()) -func NewStandard(transportCode string, logLevel logging.Level, logFormat logging.Formatter) *StandardLogger { - return &StandardLogger{ - logger: NewBase(os.Stdout, transportCode, logLevel, logFormat), - } -} - -// NewBase is a constructor for underlying logger in the StandardLogger struct. -func NewBase(out io.Writer, transportCode string, logLevel logging.Level, logFormat logging.Formatter) *logging.Logger { - logger := logging.MustGetLogger(transportCode) - logBackend := logging.NewLogBackend(out, "", 0) - formatBackend := logging.NewBackendFormatter(logBackend, logFormat) - backend1Leveled := logging.AddModuleLevel(formatBackend) - backend1Leveled.SetLevel(logLevel, "") - logger.SetBackend(backend1Leveled) - - return logger -} - -// DefaultLogFormatter will return default formatter for logs. -func DefaultLogFormatter() logging.Formatter { - return logging.MustStringFormatter( - `%{time:2006-01-02 15:04:05.000} %{level:.4s} => %{message}`, - ) -} - -// Exclusive makes logger goroutine-safe. -func (l *StandardLogger) Exclusive() *StandardLogger { - if l.mutex == nil { - l.mutex = &sync.RWMutex{} - } - - return l -} - -// SetBaseLogger replaces base logger with the provided instance. -func (l *StandardLogger) SetBaseLogger(logger *logging.Logger) *StandardLogger { - l.logger = logger - return l -} - -// lock locks logger. -func (l *StandardLogger) lock() { - if l.mutex != nil { - l.mutex.Lock() - } -} - -// unlock unlocks logger. -func (l *StandardLogger) unlock() { - if l.mutex != nil { - l.mutex.Unlock() - } -} - -// Fatal is equivalent to l.Critical(fmt.Sprint()) followed by a call to os.Exit(1). -func (l *StandardLogger) Fatal(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Fatal(args...) -} - -// Fatalf is equivalent to l.Critical followed by a call to os.Exit(1). -func (l *StandardLogger) Fatalf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Fatalf(format, args...) -} - -// Panic is equivalent to l.Critical(fmt.Sprint()) followed by a call to panic(). -func (l *StandardLogger) Panic(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Panic(args...) -} - -// Panicf is equivalent to l.Critical followed by a call to panic(). -func (l *StandardLogger) Panicf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Panicf(format, args...) -} - -// Critical logs a message using CRITICAL as log level. -func (l *StandardLogger) Critical(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Critical(args...) -} - -// Criticalf logs a message using CRITICAL as log level. -func (l *StandardLogger) Criticalf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Criticalf(format, args...) -} - -// Error logs a message using ERROR as log level. -func (l *StandardLogger) Error(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Error(args...) -} - -// Errorf logs a message using ERROR as log level. -func (l *StandardLogger) Errorf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Errorf(format, args...) -} - -// Warning logs a message using WARNING as log level. -func (l *StandardLogger) Warning(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Warning(args...) -} - -// Warningf logs a message using WARNING as log level. -func (l *StandardLogger) Warningf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Warningf(format, args...) -} - -// Notice logs a message using NOTICE as log level. -func (l *StandardLogger) Notice(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Notice(args...) -} - -// Noticef logs a message using NOTICE as log level. -func (l *StandardLogger) Noticef(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Noticef(format, args...) -} - -// Info logs a message using INFO as log level. -func (l *StandardLogger) Info(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Info(args...) -} - -// Infof logs a message using INFO as log level. -func (l *StandardLogger) Infof(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Infof(format, args...) -} - -// Debug logs a message using DEBUG as log level. -func (l *StandardLogger) Debug(args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Debug(args...) -} - -// Debugf logs a message using DEBUG as log level. -func (l *StandardLogger) Debugf(format string, args ...interface{}) { - l.lock() - defer l.unlock() - l.logger.Debugf(format, args...) -} diff --git a/core/logger/logger_test.go b/core/logger/logger_test.go deleted file mode 100644 index cb9c371..0000000 --- a/core/logger/logger_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package logger - -import ( - "bytes" - "testing" - - "github.com/op/go-logging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -type StandardLoggerTest struct { - suite.Suite - logger *StandardLogger - buf *bytes.Buffer -} - -func TestLogger_NewLogger(t *testing.T) { - logger := NewStandard("code", logging.DEBUG, DefaultLogFormatter()) - assert.NotNil(t, logger) -} - -func TestLogger_DefaultLogFormatter(t *testing.T) { - formatter := DefaultLogFormatter() - - assert.NotNil(t, formatter) - assert.IsType(t, logging.MustStringFormatter(`%{message}`), formatter) -} - -func Test_Logger(t *testing.T) { - suite.Run(t, new(StandardLoggerTest)) -} - -func (t *StandardLoggerTest) SetupSuite() { - t.buf = &bytes.Buffer{} - t.logger = (&StandardLogger{}). - Exclusive(). - SetBaseLogger(NewBase(t.buf, "code", logging.DEBUG, DefaultLogFormatter())) -} - -func (t *StandardLoggerTest) SetupTest() { - t.buf.Reset() -} - -// TODO Cover Fatal and Fatalf (implementation below is no-op) -// func (t *StandardLoggerTest) Test_Fatal() { -// if os.Getenv("FLAG") == "1" { -// t.logger.Fatal("test", "fatal") -// return -// } - -// cmd := exec.Command(os.Args[0], "-test.run=TestGetConfig") -// cmd.Env = append(os.Environ(), "FLAG=1") -// err := cmd.Run() - -// e, ok := err.(*exec.ExitError) -// expectedErrorString := "test fatal" -// t.Assert().Equal(true, ok) -// t.Assert().Equal(expectedErrorString, e.Error()) -// } - -func (t *StandardLoggerTest) Test_Panic() { - defer func() { - t.Assert().NotNil(recover()) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), "panic") - }() - t.logger.Panic("panic") -} - -func (t *StandardLoggerTest) Test_Panicf() { - defer func() { - t.Assert().NotNil(recover()) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), "panicf") - }() - t.logger.Panicf("panicf") -} - -func (t *StandardLoggerTest) Test_Critical() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), "critical") - }() - t.logger.Critical("critical") -} - -func (t *StandardLoggerTest) Test_Criticalf() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), "critical") - }() - t.logger.Criticalf("critical") -} - -func (t *StandardLoggerTest) Test_Warning() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "WARN") - t.Assert().Contains(t.buf.String(), "warning") - }() - t.logger.Warning("warning") -} - -func (t *StandardLoggerTest) Test_Notice() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "NOTI") - t.Assert().Contains(t.buf.String(), "notice") - }() - t.logger.Notice("notice") -} - -func (t *StandardLoggerTest) Test_Info() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), "info") - }() - t.logger.Info("info") -} - -func (t *StandardLoggerTest) Test_Debug() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "DEBU") - t.Assert().Contains(t.buf.String(), "debug") - }() - t.logger.Debug("debug") -} - -func (t *StandardLoggerTest) Test_Warningf() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "WARN") - t.Assert().Contains(t.buf.String(), "warning") - }() - t.logger.Warningf("%s", "warning") -} - -func (t *StandardLoggerTest) Test_Noticef() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "NOTI") - t.Assert().Contains(t.buf.String(), "notice") - }() - t.logger.Noticef("%s", "notice") -} - -func (t *StandardLoggerTest) Test_Infof() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), "info") - }() - t.logger.Infof("%s", "info") -} - -func (t *StandardLoggerTest) Test_Debugf() { - defer func() { - t.Require().Nil(recover()) - t.Assert().Contains(t.buf.String(), "DEBU") - t.Assert().Contains(t.buf.String(), "debug") - }() - t.logger.Debugf("%s", "debug") -} diff --git a/core/logger/mg_transport_client_adapter.go b/core/logger/mg_transport_client_adapter.go new file mode 100644 index 0000000..0b8569b --- /dev/null +++ b/core/logger/mg_transport_client_adapter.go @@ -0,0 +1,60 @@ +package logger + +import ( + "fmt" + + "go.uber.org/zap" + + v1 "github.com/retailcrm/mg-transport-api-client-go/v1" +) + +const ( + mgDebugLogReq = "MG TRANSPORT API Request: %s %s %s %v" + mgDebugLogReqFile = "MG TRANSPORT API Request: %s %s %s [file data]" + mgDebugLogResp = "MG TRANSPORT API Response: %s" +) + +type mgTransportClientAdapter struct { + log Logger +} + +// MGTransportClientAdapter constructs an adapter that will log MG requests and responses. +func MGTransportClientAdapter(log Logger) v1.BasicLogger { + return &mgTransportClientAdapter{log: log} +} + +// Debugf writes a message with Debug level. +func (m *mgTransportClientAdapter) Debugf(msg string, args ...interface{}) { + var body interface{} + switch msg { + case mgDebugLogReqFile: + body = "[file data]" + fallthrough + case mgDebugLogReq: + var method, uri, token string + if len(args) > 0 { + method = fmt.Sprint(args[0]) + } + if len(args) > 1 { + uri = fmt.Sprint(args[1]) + } + if len(args) > 2 { // nolint:gomnd + token = fmt.Sprint(args[2]) + } + if len(args) > 3 { // nolint:gomnd + body = args[3] + } + m.log.Debug("MG TRANSPORT API Request", + zap.String(HTTPMethodAttr, method), zap.String("url", uri), + zap.String("token", token), Body(body)) + case mgDebugLogResp: + m.log.Debug("MG TRANSPORT API Response", Body(args[0])) + default: + m.log.Debug(fmt.Sprintf(msg, args...)) + } +} + +// Printf is a v1.BasicLogger implementation. +func (m *mgTransportClientAdapter) Printf(msg string, args ...interface{}) { + m.Debugf(msg, args...) +} diff --git a/core/logger/mg_transport_client_adapter_test.go b/core/logger/mg_transport_client_adapter_test.go new file mode 100644 index 0000000..f3ece35 --- /dev/null +++ b/core/logger/mg_transport_client_adapter_test.go @@ -0,0 +1,45 @@ +package logger + +import ( + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/retailcrm/mg-transport-api-client-go/v1" +) + +func TestMGTransportClientAdapter(t *testing.T) { + httpClient := &http.Client{} + log := newJSONBufferedLogger(nil) + client := v1.NewWithClient("https://mg.dev", "test_token", httpClient). + WithLogger(MGTransportClientAdapter(log.Logger())) + client.Debug = true + + defer gock.Off() + gock.New("https://mg.dev"). + Get("/api/transport/v1/channels"). + Reply(http.StatusOK). + JSON([]v1.ChannelListItem{{ID: 123}}) + + _, _, err := client.TransportChannels(v1.Channels{}) + require.NoError(t, err) + + entries, err := log.ScanAll() + require.NoError(t, err) + require.Len(t, entries, 2) + + assert.Equal(t, "DEBUG", entries[0].LevelName) + assert.True(t, entries[0].DateTime.Valid) + assert.Equal(t, "MG TRANSPORT API Request", entries[0].Message) + assert.Equal(t, http.MethodGet, entries[0].Context["method"]) + assert.Equal(t, "test_token", entries[0].Context["token"]) + assert.Equal(t, "https://mg.dev/api/transport/v1/channels?", entries[0].Context["url"]) + + assert.Equal(t, "DEBUG", entries[1].LevelName) + assert.True(t, entries[1].DateTime.Valid) + assert.Equal(t, "MG TRANSPORT API Response", entries[1].Message) + assert.Equal(t, float64(123), entries[1].Context["body"].([]interface{})[0].(map[string]interface{})["id"]) +} diff --git a/core/logger/nil.go b/core/logger/nil.go new file mode 100644 index 0000000..bd348ed --- /dev/null +++ b/core/logger/nil.go @@ -0,0 +1,62 @@ +package logger + +import ( + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// Nil logger doesn't do anything. +type Nil struct{} + +// NewNil constructs new *Nil. +func NewNil() Logger { + return &Nil{} +} + +func (l *Nil) With(_ ...zap.Field) Logger { + return l +} + +func (l *Nil) WithLazy(_ ...zap.Field) Logger { + return l +} + +func (l *Nil) Level() zapcore.Level { + return zapcore.DebugLevel +} + +func (l *Nil) Check(_ zapcore.Level, _ string) *zapcore.CheckedEntry { + return &zapcore.CheckedEntry{} +} + +func (l *Nil) Log(_ zapcore.Level, _ string, _ ...zap.Field) {} + +func (l *Nil) Debug(_ string, _ ...zap.Field) {} + +func (l *Nil) Info(_ string, _ ...zap.Field) {} + +func (l *Nil) Warn(_ string, _ ...zap.Field) {} + +func (l *Nil) Error(_ string, _ ...zap.Field) {} + +func (l *Nil) DPanic(_ string, _ ...zap.Field) {} + +func (l *Nil) Panic(_ string, _ ...zap.Field) {} + +func (l *Nil) Fatal(_ string, _ ...zap.Field) {} + +func (l *Nil) ForHandler(_ any) Logger { + return l +} + +func (l *Nil) ForConnection(_ any) Logger { + return l +} + +func (l *Nil) ForAccount(_ any) Logger { + return l +} + +func (l *Nil) Sync() error { + return nil +} diff --git a/core/logger/nil_logger.go b/core/logger/nil_logger.go deleted file mode 100644 index 440b4d0..0000000 --- a/core/logger/nil_logger.go +++ /dev/null @@ -1,45 +0,0 @@ -package logger - -import ( - "fmt" - "os" -) - -// Nil provides Logger implementation that does almost nothing when called. -// Panic, Panicf, Fatal and Fatalf methods still cause panic and immediate program termination respectively. -// All other methods won't do anything at all. -type Nil struct{} - -func (n Nil) Fatal(args ...interface{}) { - os.Exit(1) -} - -func (n Nil) Fatalf(format string, args ...interface{}) { - os.Exit(1) -} - -func (n Nil) Panic(args ...interface{}) { - panic(fmt.Sprint(args...)) -} - -func (n Nil) Panicf(format string, args ...interface{}) { - panic(fmt.Sprintf(format, args...)) -} - -func (n Nil) Critical(args ...interface{}) {} -func (n Nil) Criticalf(format string, args ...interface{}) {} -func (n Nil) Error(args ...interface{}) {} -func (n Nil) Errorf(format string, args ...interface{}) {} -func (n Nil) Warning(args ...interface{}) {} -func (n Nil) Warningf(format string, args ...interface{}) {} -func (n Nil) Notice(args ...interface{}) {} -func (n Nil) Noticef(format string, args ...interface{}) {} -func (n Nil) Info(args ...interface{}) {} -func (n Nil) Infof(format string, args ...interface{}) {} -func (n Nil) Debug(args ...interface{}) {} -func (n Nil) Debugf(format string, args ...interface{}) {} - -// NewNil is a Nil logger constructor. -func NewNil() Logger { - return &Nil{} -} diff --git a/core/logger/nil_logger_test.go b/core/logger/nil_logger_test.go deleted file mode 100644 index f41c03e..0000000 --- a/core/logger/nil_logger_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package logger - -import ( - "bytes" - "io" - "os" - "testing" - "time" - - "github.com/stretchr/testify/suite" -) - -type NilTest struct { - suite.Suite - logger Logger - realStdout *os.File - r *os.File - w *os.File -} - -func TestNilLogger(t *testing.T) { - suite.Run(t, new(NilTest)) -} - -func (t *NilTest) SetupSuite() { - t.logger = NewNil() -} - -func (t *NilTest) SetupTest() { - t.realStdout = os.Stdout - t.r, t.w, _ = os.Pipe() - os.Stdout = t.w -} - -func (t *NilTest) TearDownTest() { - if t.realStdout != nil { - t.Require().NoError(t.w.Close()) - os.Stdout = t.realStdout - } -} - -func (t *NilTest) readStdout() string { - outC := make(chan string) - go func() { - var buf bytes.Buffer - _, err := io.Copy(&buf, t.r) - t.Require().NoError(err) - outC <- buf.String() - close(outC) - }() - - t.Require().NoError(t.w.Close()) - os.Stdout = t.realStdout - t.realStdout = nil - - select { - case c := <-outC: - return c - case <-time.After(time.Second): - return "" - } -} - -func (t *NilTest) Test_Noop() { - t.logger.Critical("message") - t.logger.Criticalf("message") - t.logger.Error("message") - t.logger.Errorf("message") - t.logger.Warning("message") - t.logger.Warningf("message") - t.logger.Notice("message") - t.logger.Noticef("message") - t.logger.Info("message") - t.logger.Infof("message") - t.logger.Debug("message") - t.logger.Debugf("message") - - t.Assert().Empty(t.readStdout()) -} - -func (t *NilTest) Test_Panic() { - t.Assert().Panics(func() { - t.logger.Panic("") - }) -} - -func (t *NilTest) Test_Panicf() { - t.Assert().Panics(func() { - t.logger.Panicf("") - }) -} diff --git a/core/logger/pool.go b/core/logger/pool.go new file mode 100644 index 0000000..51695dc --- /dev/null +++ b/core/logger/pool.go @@ -0,0 +1,57 @@ +// Copyright (c) 2023 Uber Technologies, Inc. +// +// 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. + +package logger + +import ( + "sync" +) + +// A Pool is a generic wrapper around [sync.Pool] to provide strongly-typed +// object pooling. +// +// Note that SA6002 (ref: https://staticcheck.io/docs/checks/#SA6002) will +// not be detected, so all internal pool use must take care to only store +// pointer types. +type Pool[T any] struct { + pool sync.Pool +} + +// NewPool returns a new [Pool] for T, and will use fn to construct new Ts when +// the pool is empty. +func NewPool[T any](fn func() T) *Pool[T] { + return &Pool[T]{ + pool: sync.Pool{ + New: func() any { + return fn() + }, + }, + } +} + +// Get gets a T from the pool, or creates a new one if the pool is empty. +func (p *Pool[T]) Get() T { + return p.pool.Get().(T) +} + +// Put returns x into the pool. +func (p *Pool[T]) Put(x T) { + p.pool.Put(x) +} diff --git a/core/logger/pool_test.go b/core/logger/pool_test.go new file mode 100644 index 0000000..a73da78 --- /dev/null +++ b/core/logger/pool_test.go @@ -0,0 +1,19 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPool(t *testing.T) { + p := NewPool[*uint8](func() *uint8 { + item := uint8(22) + return &item + }) + + val := p.Get() + assert.Equal(t, uint8(22), *val) + assert.Equal(t, uint8(22), *p.Get()) + p.Put(val) +} diff --git a/core/logger/prefix_decorator.go b/core/logger/prefix_decorator.go deleted file mode 100644 index 2e83912..0000000 --- a/core/logger/prefix_decorator.go +++ /dev/null @@ -1,111 +0,0 @@ -package logger - -import "github.com/op/go-logging" - -// PrefixAware is implemented if the logger allows you to change the prefix. -type PrefixAware interface { - SetPrefix(string) - Prefix() string -} - -// PrefixedLogger is a base interface for the logger with prefix. -type PrefixedLogger interface { - Logger - PrefixAware -} - -// PrefixDecorator is an implementation of the PrefixedLogger. It will allow you to decorate any Logger with -// the provided predefined prefix. -type PrefixDecorator struct { - backend Logger - prefix []interface{} -} - -// DecorateWithPrefix using provided base logger and provided prefix. -// No internal state of the base logger will be touched. -func DecorateWithPrefix(backend Logger, prefix string) PrefixedLogger { - return &PrefixDecorator{backend: backend, prefix: []interface{}{prefix}} -} - -// NewWithPrefix returns logger with prefix. It uses StandardLogger under the hood. -func NewWithPrefix(transportCode, prefix string, logLevel logging.Level, logFormat logging.Formatter) PrefixedLogger { - return DecorateWithPrefix(NewStandard(transportCode, logLevel, logFormat), prefix) -} - -// SetPrefix will replace existing prefix with the provided value. -// Use this format for prefixes: "prefix here:" - omit space at the end (it will be inserted automatically). -func (p *PrefixDecorator) SetPrefix(prefix string) { - p.prefix = []interface{}{prefix} -} - -func (p *PrefixDecorator) getFormat(fmt string) string { - return p.prefix[0].(string) + " " + fmt -} - -func (p *PrefixDecorator) Prefix() string { - return p.prefix[0].(string) -} - -func (p *PrefixDecorator) Fatal(args ...interface{}) { - p.backend.Fatal(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Fatalf(format string, args ...interface{}) { - p.backend.Fatalf(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Panic(args ...interface{}) { - p.backend.Panic(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Panicf(format string, args ...interface{}) { - p.backend.Panicf(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Critical(args ...interface{}) { - p.backend.Critical(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Criticalf(format string, args ...interface{}) { - p.backend.Criticalf(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Error(args ...interface{}) { - p.backend.Error(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Errorf(format string, args ...interface{}) { - p.backend.Errorf(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Warning(args ...interface{}) { - p.backend.Warning(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Warningf(format string, args ...interface{}) { - p.backend.Warningf(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Notice(args ...interface{}) { - p.backend.Notice(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Noticef(format string, args ...interface{}) { - p.backend.Noticef(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Info(args ...interface{}) { - p.backend.Info(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Infof(format string, args ...interface{}) { - p.backend.Infof(p.getFormat(format), args...) -} - -func (p *PrefixDecorator) Debug(args ...interface{}) { - p.backend.Debug(append(p.prefix, args...)...) -} - -func (p *PrefixDecorator) Debugf(format string, args ...interface{}) { - p.backend.Debugf(p.getFormat(format), args...) -} diff --git a/core/logger/prefix_decorator_test.go b/core/logger/prefix_decorator_test.go deleted file mode 100644 index 5b66ec5..0000000 --- a/core/logger/prefix_decorator_test.go +++ /dev/null @@ -1,167 +0,0 @@ -package logger - -import ( - "bytes" - "testing" - - "github.com/op/go-logging" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/suite" -) - -const testPrefix = "TestPrefix:" - -type PrefixDecoratorTest struct { - suite.Suite - buf *bytes.Buffer - logger PrefixedLogger -} - -func TestPrefixDecorator(t *testing.T) { - suite.Run(t, new(PrefixDecoratorTest)) -} - -func TestNewWithPrefix(t *testing.T) { - buf := &bytes.Buffer{} - logger := NewWithPrefix("code", "Prefix:", logging.DEBUG, DefaultLogFormatter()) - logger.(*PrefixDecorator).backend.(*StandardLogger). - SetBaseLogger(NewBase(buf, "code", logging.DEBUG, DefaultLogFormatter())) - logger.Debugf("message %s", "text") - - assert.Contains(t, buf.String(), "Prefix: message text") -} - -func (t *PrefixDecoratorTest) SetupSuite() { - t.buf = &bytes.Buffer{} - t.logger = DecorateWithPrefix((&StandardLogger{}). - SetBaseLogger(NewBase(t.buf, "code", logging.DEBUG, DefaultLogFormatter())), testPrefix) -} - -func (t *PrefixDecoratorTest) SetupTest() { - t.buf.Reset() - t.logger.SetPrefix(testPrefix) -} - -func (t *PrefixDecoratorTest) Test_SetPrefix() { - t.logger.Info("message") - t.Assert().Equal(testPrefix, t.logger.Prefix()) - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), testPrefix+" message") - - t.logger.SetPrefix(testPrefix + testPrefix) - t.logger.Info("message") - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), testPrefix+testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Panic() { - t.Require().Panics(func() { - t.logger.Panic("message") - }) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Panicf() { - t.Require().Panics(func() { - t.logger.Panicf("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Critical() { - t.Require().NotPanics(func() { - t.logger.Critical("message") - }) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Criticalf() { - t.Require().NotPanics(func() { - t.logger.Criticalf("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "CRIT") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Error() { - t.Require().NotPanics(func() { - t.logger.Error("message") - }) - t.Assert().Contains(t.buf.String(), "ERRO") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Errorf() { - t.Require().NotPanics(func() { - t.logger.Errorf("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "ERRO") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Warning() { - t.Require().NotPanics(func() { - t.logger.Warning("message") - }) - t.Assert().Contains(t.buf.String(), "WARN") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Warningf() { - t.Require().NotPanics(func() { - t.logger.Warningf("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "WARN") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Notice() { - t.Require().NotPanics(func() { - t.logger.Notice("message") - }) - t.Assert().Contains(t.buf.String(), "NOTI") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Noticef() { - t.Require().NotPanics(func() { - t.logger.Noticef("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "NOTI") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Info() { - t.Require().NotPanics(func() { - t.logger.Info("message") - }) - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Infof() { - t.Require().NotPanics(func() { - t.logger.Infof("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "INFO") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Debug() { - t.Require().NotPanics(func() { - t.logger.Debug("message") - }) - t.Assert().Contains(t.buf.String(), "DEBU") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} - -func (t *PrefixDecoratorTest) Test_Debugf() { - t.Require().NotPanics(func() { - t.logger.Debugf("%s", "message") - }) - t.Assert().Contains(t.buf.String(), "DEBU") - t.Assert().Contains(t.buf.String(), testPrefix+" message") -} diff --git a/core/logger/writer_adapter.go b/core/logger/writer_adapter.go new file mode 100644 index 0000000..cceca9e --- /dev/null +++ b/core/logger/writer_adapter.go @@ -0,0 +1,22 @@ +package logger + +import ( + "io" + + "go.uber.org/zap/zapcore" +) + +type writerAdapter struct { + log Logger + level zapcore.Level +} + +// WriterAdapter returns an io.Writer that can be used to write log messages. Message level is preconfigured. +func WriterAdapter(log Logger, level zapcore.Level) io.Writer { + return &writerAdapter{log: log, level: level} +} + +func (w *writerAdapter) Write(p []byte) (n int, err error) { + w.log.Log(w.level, string(p)) + return len(p), nil +} diff --git a/core/logger/writer_adapter_test.go b/core/logger/writer_adapter_test.go new file mode 100644 index 0000000..68e34d2 --- /dev/null +++ b/core/logger/writer_adapter_test.go @@ -0,0 +1,25 @@ +package logger + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/zap" +) + +func TestWriterAdapter(t *testing.T) { + log := newBufferLogger() + adapter := WriterAdapter(log, zap.InfoLevel) + + msg := []byte("hello world") + total, err := adapter.Write(msg) + require.NoError(t, err) + require.Equal(t, total, len(msg)) + + items, err := newJSONBufferedLogger(log).ScanAll() + require.NoError(t, err) + require.Len(t, items, 1) + assert.Equal(t, "hello world", items[0].Message) + assert.Equal(t, "INFO", items[0].LevelName) +} diff --git a/core/logger/zabbix_collector_adapter.go b/core/logger/zabbix_collector_adapter.go new file mode 100644 index 0000000..f33face --- /dev/null +++ b/core/logger/zabbix_collector_adapter.go @@ -0,0 +1,34 @@ +package logger + +import ( + "fmt" + + metrics "github.com/retailcrm/zabbix-metrics-collector" +) + +type zabbixCollectorAdapter struct { + log Logger +} + +func (a *zabbixCollectorAdapter) Errorf(format string, args ...interface{}) { + baseMsg := "cannot send metrics to Zabbix" + switch format { + case "cannot stop collector: %s": + baseMsg = "cannot stop Zabbix collector" + fallthrough + case "cannot send metrics to Zabbix: %v": + var err interface{} + if len(args) > 0 { + err = args[0] + } + a.log.Error(baseMsg, Err(err)) + default: + a.log.Error(fmt.Sprintf(format, args...)) + } +} + +// ZabbixCollectorAdapter works as a logger adapter for Zabbix metrics collector. +// It can extract error messages from Zabbix collector and convert them to structured format. +func ZabbixCollectorAdapter(log Logger) metrics.ErrorLogger { + return &zabbixCollectorAdapter{log: log} +} diff --git a/core/logger/zabbix_collector_adapter_test.go b/core/logger/zabbix_collector_adapter_test.go new file mode 100644 index 0000000..08f9dfb --- /dev/null +++ b/core/logger/zabbix_collector_adapter_test.go @@ -0,0 +1,26 @@ +package logger + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestZabbixCollectorAdapter(t *testing.T) { + log := newBufferLogger() + adapter := ZabbixCollectorAdapter(log) + adapter.Errorf("highly unexpected error: %s", "unexpected error") + adapter.Errorf("cannot stop collector: %s", "app error") + adapter.Errorf("cannot send metrics to Zabbix: %v", errors.New("send error")) + + items, err := newJSONBufferedLogger(log).ScanAll() + require.NoError(t, err) + require.Len(t, items, 3) + assert.Equal(t, "highly unexpected error: unexpected error", items[0].Message) + assert.Equal(t, "cannot stop Zabbix collector", items[1].Message) + assert.Equal(t, "app error", items[1].Context[ErrorAttr]) + assert.Equal(t, "cannot send metrics to Zabbix", items[2].Message) + assert.Equal(t, "send error", items[2].Context[ErrorAttr]) +} diff --git a/core/logger/zap.go b/core/logger/zap.go new file mode 100644 index 0000000..1b8730b --- /dev/null +++ b/core/logger/zap.go @@ -0,0 +1,102 @@ +package logger + +import ( + "fmt" + "time" + + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +func NewZap(format string, debug bool) *zap.Logger { + switch format { + case "json": + return NewZapJSON(debug) + case "console": + return NewZapConsole(debug) + default: + panic(fmt.Sprintf("unknown logger format: %s", format)) + } +} + +func NewZapConsole(debug bool) *zap.Logger { + level := zapcore.InfoLevel + if debug { + level = zapcore.DebugLevel + } + log, err := zap.Config{ + Level: zap.NewAtomicLevelAt(level), + Development: debug, + Encoding: "console", + EncoderConfig: EncoderConfigConsole(), + OutputPaths: []string{"stdout"}, + ErrorOutputPaths: []string{"stderr"}, + }.Build() + if err != nil { + panic(err) + } + return log +} + +func EncoderConfigConsole() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + MessageKey: "message", + LevelKey: "level", + TimeKey: "datetime", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + StacktraceKey: "", + LineEnding: "\n", + EncodeLevel: func(level zapcore.Level, encoder zapcore.PrimitiveArrayEncoder) { + encoder.AppendString("level_name=" + level.CapitalString()) + }, + EncodeTime: func(t time.Time, encoder zapcore.PrimitiveArrayEncoder) { + encoder.AppendString("datetime=" + t.Format(time.RFC3339)) + }, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: func(caller zapcore.EntryCaller, encoder zapcore.PrimitiveArrayEncoder) { + encoder.AppendString("caller=" + caller.TrimmedPath()) + }, + EncodeName: zapcore.FullNameEncoder, + ConsoleSeparator: " ", + } +} + +func NewZapJSON(debug bool) *zap.Logger { + level := zapcore.InfoLevel + if debug { + level = zapcore.DebugLevel + } + log, err := zap.Config{ + Level: zap.NewAtomicLevelAt(level), + Development: debug, + Encoding: "json-with-context", + EncoderConfig: EncoderConfigJSON(), + OutputPaths: []string{"stdout"}, + ErrorOutputPaths: []string{"stderr"}, + }.Build() + if err != nil { + panic(err) + } + return log +} + +func EncoderConfigJSON() zapcore.EncoderConfig { + return zapcore.EncoderConfig{ + MessageKey: "message", + LevelKey: "level_name", + TimeKey: "datetime", + NameKey: "logger", + CallerKey: "caller", + FunctionKey: zapcore.OmitKey, + StacktraceKey: "", + LineEnding: "\n", + EncodeLevel: zapcore.CapitalLevelEncoder, + EncodeTime: zapcore.RFC3339TimeEncoder, + EncodeDuration: zapcore.StringDurationEncoder, + EncodeCaller: zapcore.ShortCallerEncoder, + EncodeName: zapcore.FullNameEncoder, + ConsoleSeparator: " ", + } +} diff --git a/core/middleware/csrf.go b/core/middleware/csrf.go index 3fe4716..f8b59b0 100644 --- a/core/middleware/csrf.go +++ b/core/middleware/csrf.go @@ -6,7 +6,6 @@ import ( "crypto/sha1" "encoding/base64" "io" - "io/ioutil" "math/rand" "time" @@ -57,10 +56,10 @@ var DefaultCSRFTokenGetter = func(c *gin.Context) string { } else if t := r.Header.Get("X-XSRF-Token"); len(t) > 0 { return t } else if c.Request.Body != nil { - data, _ := ioutil.ReadAll(c.Request.Body) - c.Request.Body = ioutil.NopCloser(bytes.NewReader(data)) + data, _ := io.ReadAll(c.Request.Body) + c.Request.Body = io.NopCloser(bytes.NewReader(data)) t := r.FormValue("csrf_token") - c.Request.Body = ioutil.NopCloser(bytes.NewReader(data)) + c.Request.Body = io.NopCloser(bytes.NewReader(data)) if len(t) > 0 { return t @@ -91,17 +90,20 @@ type CSRF struct { // csrfTokenGetter will be used to obtain token. // // Usage (with random salt): -// core.NewCSRF("", "super secret", "csrf_session", store, func (c *gin.Context, reason core.CSRFErrorReason) { -// c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid CSRF token"}) -// }, core.DefaultCSRFTokenGetter) +// +// core.NewCSRF("", "super secret", "csrf_session", store, func (c *gin.Context, reason core.CSRFErrorReason) { +// c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": "Invalid CSRF token"}) +// }, core.DefaultCSRFTokenGetter) // // Note for csrfTokenGetter: if you want to read token from request body (for example, from form field) // - don't forget to restore Body data! // // Body in http.Request is io.ReadCloser instance. Reading CSRF token from form like that: -// if t := r.FormValue("csrf_token"); len(t) > 0 { -// return t -// } +// +// if t := r.FormValue("csrf_token"); len(t) > 0 { +// return t +// } +// // will close body - and all next middlewares won't be able to read body at all! // // Use DefaultCSRFTokenGetter as example to implement your own token getter. @@ -185,11 +187,11 @@ func (x *CSRF) generateSalt() string { // pseudoRandomString generates pseudo-random string with specified length. func (x *CSRF) pseudoRandomString(length int) string { - rand.Seed(time.Now().UnixNano()) + r := rand.New(rand.NewSource(time.Now().UnixNano())) // nolint:gosec data := make([]byte, length) for i := 0; i < length; i++ { // it is supposed to use pseudo-random data. - data[i] = byte(65 + rand.Intn(90-65)) // nolint:gosec,gomnd + data[i] = byte(65 + r.Intn(90-65)) // nolint:gosec,gomnd } return string(data) @@ -209,15 +211,16 @@ func (x *CSRF) CSRFFromContext(c *gin.Context) string { // GenerateCSRFMiddleware returns gin.HandlerFunc which will generate CSRF token // Usage: -// engine := gin.New() -// csrf := NewCSRF("salt", "secret", "not_found", "incorrect", localizer) -// engine.Use(csrf.GenerateCSRFMiddleware()) +// +// engine := gin.New() +// csrf := NewCSRF("salt", "secret", "not_found", "incorrect", localizer) +// engine.Use(csrf.GenerateCSRFMiddleware()) func (x *CSRF) GenerateCSRFMiddleware() gin.HandlerFunc { return func(c *gin.Context) { session, _ := x.store.Get(c.Request, x.sessionName) - if i, ok := session.Values["csrf_token"]; ok { - if i, ok := i.(string); !ok || i == "" { + if i, ok := session.Values["csrf_token"]; ok { // nolint:nestif + if i, ok := i.(string); !ok || i == "" { // nolint:nestif if x.fillToken(session, c) != nil { x.abortFunc(c, CSRFErrorCannotStoreTokenInSession) c.Abort() @@ -243,8 +246,9 @@ func (x *CSRF) fillToken(s *sessions.Session, c *gin.Context) error { // VerifyCSRFMiddleware verifies CSRF token // Usage: -// engine := gin.New() -// engine.Use(csrf.VerifyCSRFMiddleware()) +// +// engine := gin.New() +// engine.Use(csrf.VerifyCSRFMiddleware()) func (x *CSRF) VerifyCSRFMiddleware(ignoredMethods []string) gin.HandlerFunc { return func(c *gin.Context) { if x.strInSlice(ignoredMethods, c.Request.Method) { @@ -254,9 +258,9 @@ func (x *CSRF) VerifyCSRFMiddleware(ignoredMethods []string) gin.HandlerFunc { var token string session, _ := x.store.Get(c.Request, x.sessionName) - if i, ok := session.Values["csrf_token"]; ok { + if i, ok := session.Values["csrf_token"]; ok { // nolint:nestif var v string - if v, ok = i.(string); !ok || v == "" { + if v, ok = i.(string); !ok || v == "" { // nolint:nestif if !ok { x.abortFunc(c, CSRFErrorIncorrectTokenType) } else if v == "" { diff --git a/core/middleware/csrf_test.go b/core/middleware/csrf_test.go index 5f7b831..6d501a7 100644 --- a/core/middleware/csrf_test.go +++ b/core/middleware/csrf_test.go @@ -3,7 +3,6 @@ package middleware import ( "bytes" "io" - "io/ioutil" "net/http" "net/http/httptest" "net/url" @@ -32,7 +31,7 @@ func TestCSRF_DefaultCSRFTokenGetter_Empty(t *testing.T) { URL: &url.URL{ RawQuery: "", }, - Body: ioutil.NopCloser(bytes.NewReader([]byte(""))), + Body: io.NopCloser(bytes.NewReader([]byte(""))), }} assert.Empty(t, DefaultCSRFTokenGetter(c)) @@ -85,14 +84,14 @@ func TestCSRF_DefaultCSRFTokenGetter_Form(t *testing.T) { RawQuery: "", }, Header: headers, - Body: ioutil.NopCloser(bytes.NewReader([]byte(""))), + Body: io.NopCloser(bytes.NewReader([]byte(""))), }} c.Request.PostForm = url.Values{"csrf_token": {"token"}} assert.NotEmpty(t, DefaultCSRFTokenGetter(c)) assert.Equal(t, "token", DefaultCSRFTokenGetter(c)) - _, err := ioutil.ReadAll(c.Request.Body) + _, err := io.ReadAll(c.Request.Body) assert.NoError(t, err) } diff --git a/core/module_features_uploader.go b/core/module_features_uploader.go index f2979d9..d014938 100644 --- a/core/module_features_uploader.go +++ b/core/module_features_uploader.go @@ -12,6 +12,7 @@ import ( "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/gomarkdown/markdown" + "go.uber.org/zap" "golang.org/x/text/language" "github.com/retailcrm/mg-transport-core/v2/core/config" @@ -64,14 +65,13 @@ func NewModuleFeaturesUploader( awsConfig.WithCredentialsProvider(customProvider), ) if err != nil { - log.Fatal(err) + log.Error("cannot load S3 configuration", logger.Err(err)) return nil } client := manager.NewUploader(s3.NewFromConfig(cfg)) - if err != nil { - log.Fatal(err) + log.Error("cannot load S3 configuration", logger.Err(err)) return nil } @@ -87,18 +87,18 @@ func NewModuleFeaturesUploader( } func (s *ModuleFeaturesUploader) Upload() { - s.log.Debugf("upload module features started...") + s.log.Debug("upload module features started...") content, err := os.ReadFile(s.featuresFilename) if err != nil { - s.log.Errorf("cannot read markdown file %s %s", s.featuresFilename, err.Error()) + s.log.Error("cannot read markdown file %s %s", zap.String("fileName", s.featuresFilename), logger.Err(err)) return } for _, lang := range languages { translated, err := s.translate(content, lang) if err != nil { - s.log.Errorf("cannot translate module features file to %s: %s", lang.String(), err.Error()) + s.log.Error("cannot translate module features file", zap.String("lang", lang.String()), logger.Err(err)) continue } @@ -106,7 +106,7 @@ func (s *ModuleFeaturesUploader) Upload() { resp, err := s.uploadFile(html, lang.String()) if err != nil { - s.log.Errorf("cannot upload file %s: %s", lang.String(), err.Error()) + s.log.Error("cannot upload file", zap.String("lang", lang.String()), logger.Err(err)) continue } @@ -114,7 +114,7 @@ func (s *ModuleFeaturesUploader) Upload() { } fmt.Println() - s.log.Debugf("upload module features finished") + s.log.Debug("upload module features finished") } func (s *ModuleFeaturesUploader) translate(content []byte, lang language.Tag) ([]byte, error) { diff --git a/core/module_features_uploader_test.go b/core/module_features_uploader_test.go index 60bc9b1..936d688 100644 --- a/core/module_features_uploader_test.go +++ b/core/module_features_uploader_test.go @@ -1,19 +1,18 @@ package core import ( - "bytes" "context" - "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "os" "testing" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" + "github.com/retailcrm/mg-transport-core/v2/core/util/testutil" + "github.com/aws/aws-sdk-go-v2/service/s3" - "github.com/op/go-logging" "github.com/stretchr/testify/suite" "golang.org/x/text/language" "github.com/retailcrm/mg-transport-core/v2/core/config" - "github.com/retailcrm/mg-transport-core/v2/core/logger" ) type ModuleFeaturesUploaderTest struct { @@ -36,8 +35,7 @@ func (t *ModuleFeaturesUploaderTest) TearDownSuite() { } func (t *ModuleFeaturesUploaderTest) TestModuleFeaturesUploader_NewModuleFeaturesUploader() { - logs := &bytes.Buffer{} - log := logger.NewBase(logs, "code", logging.DEBUG, logger.DefaultLogFormatter()) + log := testutil.NewBufferedLogger() conf := config.AWS{Bucket: "bucketName", FolderName: "folder/name"} uploader := NewModuleFeaturesUploader(log, conf, t.localizer, "filename.txt") @@ -50,8 +48,7 @@ func (t *ModuleFeaturesUploaderTest) TestModuleFeaturesUploader_NewModuleFeature } func (t *ModuleFeaturesUploaderTest) TestModuleFeaturesUploader_translate() { - logs := &bytes.Buffer{} - log := logger.NewBase(logs, "code", logging.DEBUG, logger.DefaultLogFormatter()) + log := testutil.NewBufferedLogger() conf := config.AWS{Bucket: "bucketName", FolderName: "folder/name"} uploader := NewModuleFeaturesUploader(log, conf, t.localizer, "filename.txt") content := "test content " + t.localizer.GetLocalizedMessage("message") @@ -62,8 +59,7 @@ func (t *ModuleFeaturesUploaderTest) TestModuleFeaturesUploader_translate() { } func (t *ModuleFeaturesUploaderTest) TestModuleFeaturesUploader_uploadFile() { - logs := &bytes.Buffer{} - log := logger.NewBase(logs, "code", logging.DEBUG, logger.DefaultLogFormatter()) + log := testutil.NewBufferedLogger() conf := config.AWS{Bucket: "bucketName", FolderName: "folder/name"} uploader := NewModuleFeaturesUploader(log, conf, t.localizer, "source.md") content := "test content" diff --git a/core/sentry.go b/core/sentry.go index 3511c1c..9f6763f 100644 --- a/core/sentry.go +++ b/core/sentry.go @@ -15,6 +15,7 @@ import ( "github.com/getsentry/sentry-go" sentrygin "github.com/getsentry/sentry-go/gin" "github.com/pkg/errors" + "go.uber.org/zap" "github.com/retailcrm/mg-transport-core/v2/core/logger" "github.com/retailcrm/mg-transport-core/v2/core/stacktrace" @@ -22,8 +23,7 @@ import ( "github.com/gin-gonic/gin" ) -// reset is borrowed directly from the gin. -const reset = "\033[0m" +const recoveryMiddlewareSkipFrames = 3 // ErrorHandlerFunc will handle errors. type ErrorHandlerFunc func(recovery interface{}, c *gin.Context) @@ -142,17 +142,17 @@ func (s *Sentry) SentryMiddlewares() []gin.HandlerFunc { // obtainErrorLogger extracts logger from the context or builds it right here from tags used in Sentry events // Those tags can be configured with SentryLoggerConfig field. -func (s *Sentry) obtainErrorLogger(c *gin.Context) logger.AccountLogger { +func (s *Sentry) obtainErrorLogger(c *gin.Context) logger.Logger { if item, ok := c.Get("logger"); ok { - if accountLogger, ok := item.(logger.AccountLogger); ok { - return accountLogger + if ctxLogger, ok := item.(logger.Logger); ok { + return ctxLogger } } connectionID := "{no connection ID}" accountID := "{no account ID}" if s.SentryLoggerConfig.TagForConnection == "" && s.SentryLoggerConfig.TagForAccount == "" { - return logger.DecorateForAccount(s.Logger, "Sentry", connectionID, accountID) + return s.Logger.ForHandler("Sentry").ForConnection(connectionID).ForAccount(accountID) } for tag := range s.tagsFromContext(c) { @@ -164,7 +164,7 @@ func (s *Sentry) obtainErrorLogger(c *gin.Context) logger.AccountLogger { } } - return logger.DecorateForAccount(s.Logger, "Sentry", connectionID, accountID) + return s.Logger.ForHandler("Sentry").ForConnection(connectionID).ForAccount(accountID) } // tagsSetterMiddleware sets event tags into Sentry events. @@ -201,13 +201,13 @@ func (s *Sentry) exceptionCaptureMiddleware() gin.HandlerFunc { // nolint:gocogn for _, err := range publicErrors { messages[index] = err.Error() s.CaptureException(c, err) - l.Error(err) + l.Error(err.Error()) index++ } for _, err := range privateErrors { s.CaptureException(c, err) - l.Error(err) + l.Error(err.Error()) } if privateLen > 0 || recovery != nil { @@ -250,8 +250,9 @@ func (s *Sentry) recoveryMiddleware() gin.HandlerFunc { // nolint } } if l != nil { - stack := stacktrace.FormattedStack(3, l.Prefix()+" ") - formattedErr := fmt.Sprintf("%s %s", l.Prefix(), err) + // TODO: Check if we can output stacktraces with prefix data like before if we really need it. + stack := stacktrace.FormattedStack(recoveryMiddlewareSkipFrames, "trace: ") + formattedErr := logger.Err(err) httpRequest, _ := httputil.DumpRequest(c.Request, false) headers := strings.Split(string(httpRequest), "\r\n") for idx, header := range headers { @@ -259,18 +260,17 @@ func (s *Sentry) recoveryMiddleware() gin.HandlerFunc { // nolint if current[0] == "Authorization" { headers[idx] = current[0] + ": *" } - headers[idx] = l.Prefix() + " " + headers[idx] + headers[idx] = "header: " + headers[idx] } - headersToStr := strings.Join(headers, "\r\n") + headersToStr := zap.String("headers", strings.Join(headers, "\r\n")) + formattedStack := zap.String("stacktrace", string(stack)) switch { case brokenPipe: - l.Errorf("%s\n%s%s", formattedErr, headersToStr, reset) + l.Error("error", formattedErr, headersToStr) case gin.IsDebugging(): - l.Errorf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s", - timeFormat(time.Now()), headersToStr, formattedErr, stack, reset) + l.Error("[Recovery] panic recovered", headersToStr, formattedErr, formattedStack) default: - l.Errorf("[Recovery] %s panic recovered:\n%s\n%s%s", - timeFormat(time.Now()), formattedErr, stack, reset) + l.Error("[Recovery] panic recovered", formattedErr, formattedStack) } } if brokenPipe { diff --git a/core/sentry_test.go b/core/sentry_test.go index 9a8d319..2857725 100644 --- a/core/sentry_test.go +++ b/core/sentry_test.go @@ -2,7 +2,6 @@ package core import ( "errors" - "fmt" "net/http" "net/http/httptest" "sync" @@ -32,12 +31,12 @@ type sentryMockTransport struct { sending sync.RWMutex } -func (s *sentryMockTransport) Flush(timeout time.Duration) bool { +func (s *sentryMockTransport) Flush(_ time.Duration) bool { // noop return true } -func (s *sentryMockTransport) Configure(options sentry.ClientOptions) { +func (s *sentryMockTransport) Configure(_ sentry.ClientOptions) { // noop } @@ -278,7 +277,7 @@ func (s *SentryTest) TestSentry_CaptureException() { func (s *SentryTest) TestSentry_obtainErrorLogger_Existing() { ctx, _ := s.ginCtxMock() - log := logger.DecorateForAccount(testutil.NewBufferedLogger(), "component", "conn", "acc") + log := testutil.NewBufferedLogger().ForHandler("component").ForConnection("conn").ForAccount("acc") ctx.Set("logger", log) s.Assert().Equal(log, s.sentry.obtainErrorLogger(ctx)) @@ -299,12 +298,8 @@ func (s *SentryTest) TestSentry_obtainErrorLogger_Constructed() { s.Assert().NotNil(log) s.Assert().NotNil(logNoConfig) - s.Assert().Implements((*logger.AccountLogger)(nil), log) - s.Assert().Implements((*logger.AccountLogger)(nil), logNoConfig) - s.Assert().Equal( - fmt.Sprintf(logger.DefaultAccountLoggerFormat, "Sentry", "{no connection ID}", "{no account ID}"), - logNoConfig.Prefix()) - s.Assert().Equal(fmt.Sprintf(logger.DefaultAccountLoggerFormat, "Sentry", "conn_url", "acc_name"), log.Prefix()) + s.Assert().Implements((*logger.Logger)(nil), log) + s.Assert().Implements((*logger.Logger)(nil), logNoConfig) } func (s *SentryTest) TestSentry_MiddlewaresError() { diff --git a/core/stacktrace/error_test.go b/core/stacktrace/error_test.go index fdc205e..0ff8a51 100644 --- a/core/stacktrace/error_test.go +++ b/core/stacktrace/error_test.go @@ -18,7 +18,7 @@ func TestError(t *testing.T) { func (t *ErrorTest) TestAppendToError() { err := errors.New("test error") - _, ok := err.(StackTraced) + _, ok := err.(StackTraced) // nolint:errorlint t.Assert().False(ok) @@ -28,16 +28,16 @@ func (t *ErrorTest) TestAppendToError() { t.Assert().Nil(AppendToError(nil)) t.Assert().Implements((*StackTraced)(nil), withTrace) t.Assert().Implements((*StackTraced)(nil), twiceTrace) - t.Assert().Equal(withTrace.(StackTraced).StackTrace(), twiceTrace.(StackTraced).StackTrace()) + t.Assert().Equal(withTrace.(StackTraced).StackTrace(), twiceTrace.(StackTraced).StackTrace()) // nolint:errorlint } func (t *ErrorTest) TestCauseUnwrap() { err := errors.New("test error") wrapped := AppendToError(err) - t.Assert().Equal(err, wrapped.(*withStack).Cause()) + t.Assert().Equal(err, wrapped.(*withStack).Cause()) // nolint:errorlint t.Assert().Equal(err, errors.Unwrap(wrapped)) - t.Assert().Equal(wrapped.(*withStack).Cause(), errors.Unwrap(wrapped)) + t.Assert().Equal(wrapped.(*withStack).Cause(), errors.Unwrap(wrapped)) // nolint:errorlint } func (t *ErrorTest) TestFormat() { diff --git a/core/stacktrace/stack.go b/core/stacktrace/stack.go index b6262a2..d66d6e9 100644 --- a/core/stacktrace/stack.go +++ b/core/stacktrace/stack.go @@ -5,7 +5,7 @@ import ( "bytes" "fmt" "io" - "io/ioutil" + "os" "path" "runtime" "strconv" @@ -63,34 +63,34 @@ func (f Frame) name() string { // Format formats the frame according to the fmt.Formatter interface. // -// %s source file -// %d source line -// %n function name -// %v equivalent to %s:%d +// %s source file +// %d source line +// %n function name +// %v equivalent to %s:%d // // Format accepts flags that alter the printing of some verbs, as follows: // -// %+s function name and path of source file relative to the compile time -// GOPATH separated by \n\t (\n\t) -// %+v equivalent to %+s:%d +// %+s function name and path of source file relative to the compile time +// GOPATH separated by \n\t (\n\t) +// %+v equivalent to %+s:%d func (f Frame) Format(s fmt.State, verb rune) { switch verb { case 's': switch { case s.Flag('+'): - io.WriteString(s, f.name()) - io.WriteString(s, "\n\t") - io.WriteString(s, f.file()) + _, _ = io.WriteString(s, f.name()) + _, _ = io.WriteString(s, "\n\t") + _, _ = io.WriteString(s, f.file()) default: - io.WriteString(s, path.Base(f.file())) + _, _ = io.WriteString(s, path.Base(f.file())) } case 'd': - io.WriteString(s, strconv.Itoa(f.line())) + _, _ = io.WriteString(s, strconv.Itoa(f.line())) case 'n': - io.WriteString(s, funcname(f.name())) + _, _ = io.WriteString(s, funcname(f.name())) case 'v': f.Format(s, 's') - io.WriteString(s, ":") + _, _ = io.WriteString(s, ":") f.Format(s, 'd') } } @@ -110,23 +110,23 @@ type StackTrace []Frame // Format formats the stack of Frames according to the fmt.Formatter interface. // -// %s lists source files for each Frame in the stack -// %v lists the source file and line number for each Frame in the stack +// %s lists source files for each Frame in the stack +// %v lists the source file and line number for each Frame in the stack // // Format accepts flags that alter the printing of some verbs, as follows: // -// %+v Prints filename, function, and line number for each Frame in the stack. +// %+v Prints filename, function, and line number for each Frame in the stack. func (st StackTrace) Format(s fmt.State, verb rune) { switch verb { case 'v': switch { case s.Flag('+'): for _, f := range st { - io.WriteString(s, "\n") + _, _ = io.WriteString(s, "\n") f.Format(s, verb) } case s.Flag('#'): - fmt.Fprintf(s, "%#v", []Frame(st)) + _, _ = fmt.Fprintf(s, "%#v", []Frame(st)) default: st.formatSlice(s, verb) } @@ -138,14 +138,14 @@ func (st StackTrace) Format(s fmt.State, verb rune) { // formatSlice will format this StackTrace into the given buffer as a slice of // Frame, only valid when called with '%s' or '%v'. func (st StackTrace) formatSlice(s fmt.State, verb rune) { - io.WriteString(s, "[") + _, _ = io.WriteString(s, "[") for i, f := range st { if i > 0 { - io.WriteString(s, " ") + _, _ = io.WriteString(s, " ") } f.Format(s, verb) } - io.WriteString(s, "]") + _, _ = io.WriteString(s, "]") } // stack represents a stack of program counters. @@ -159,7 +159,7 @@ func (s *stack) Format(st fmt.State, verb rune) { if verb == 'v' && st.Flag('+') { for _, pc := range *s { f := Frame(pc) - fmt.Fprintf(st, "\n%+v", f) + _, _ = fmt.Fprintf(st, "\n%+v", f) } } } @@ -185,16 +185,16 @@ func FormattedStack(skip int, prefix string) []byte { break } // Print this much at least. If we can't find the source, it won't show. - fmt.Fprintf(buf, "%s%s:%d (0x%x)\n", prefix, file, line, pc) + _, _ = fmt.Fprintf(buf, "%s%s:%d (0x%x)\n", prefix, file, line, pc) if file != lastFile { - data, err := ioutil.ReadFile(file) + data, err := os.ReadFile(file) if err != nil { continue } lines = bytes.Split(data, []byte{'\n'}) lastFile = file } - fmt.Fprintf(buf, "%s\t%s: %s\n", prefix, function(pc), source(lines, line)) + _, _ = fmt.Fprintf(buf, "%s\t%s: %s\n", prefix, function(pc), source(lines, line)) } return buf.Bytes() } diff --git a/core/template_test.go b/core/template_test.go index 21ab439..48a618a 100644 --- a/core/template_test.go +++ b/core/template_test.go @@ -3,7 +3,6 @@ package core import ( "fmt" "html/template" - "io/ioutil" "os" "path" "testing" @@ -37,8 +36,8 @@ func (t *TemplateTest) initTestData() { require.Nil(t.T(), err) data1 := []byte(`data {{template "body" .}}`) data2 := []byte(`{{define "body"}}test {{"test" | trans}}{{end}}`) - err1 := ioutil.WriteFile(fmt.Sprintf(testTemplatesFile, 1), data1, os.ModePerm) - err2 := ioutil.WriteFile(fmt.Sprintf(testTemplatesFile, 2), data2, os.ModePerm) + err1 := os.WriteFile(fmt.Sprintf(testTemplatesFile, 1), data1, os.ModePerm) + err2 := os.WriteFile(fmt.Sprintf(testTemplatesFile, 2), data2, os.ModePerm) require.Nil(t.T(), err1) require.Nil(t.T(), err2) } diff --git a/core/util/errorutil/err_collector.go b/core/util/errorutil/err_collector.go index bc6b669..d68c90e 100644 --- a/core/util/errorutil/err_collector.go +++ b/core/util/errorutil/err_collector.go @@ -18,36 +18,41 @@ import ( // because AsError() returns nil if there are no errors in the list. // // Example: -// err := errorutil.NewCollector(). -// Do(errors.New("error 1")). -// Do(errors.New("error 2"), errors.New("error 3")) -// // Will print error message. -// fmt.Println(err) +// +// err := errorutil.NewCollector(). +// Do(errors.New("error 1")). +// Do(errors.New("error 2"), errors.New("error 3")) +// // Will print error message. +// fmt.Println(err) // // This code will produce something like this: -// #1 err at /home/user/main.go:62: error 1 -// #2 err at /home/user/main.go:63: error 2 -// #3 err at /home/user/main.go:64: error 3 +// +// #1 err at /home/user/main.go:62: error 1 +// #2 err at /home/user/main.go:63: error 2 +// #3 err at /home/user/main.go:64: error 3 // // You can also iterate over the error to use their data instead of using predefined message: -// err := errorutil.NewCollector(). -// Do(errors.New("error 1")). -// Do(errors.New("error 2"), errors.New("error 3")) // -// for err := range c.Iterate() { -// fmt.Printf("Error at %s:%d: %v\n", err.File, err.Line, err) -// } +// err := errorutil.NewCollector(). +// Do(errors.New("error 1")). +// Do(errors.New("error 2"), errors.New("error 3")) +// +// for err := range c.Iterate() { +// fmt.Printf("Error at %s:%d: %v\n", err.File, err.Line, err) +// } // // This code will produce output that looks like this: -// Error at /home/user/main.go:164: error 0 -// Error at /home/user/main.go:164: error 1 -// Error at /home/user/main.go:164: error 2 +// +// Error at /home/user/main.go:164: error 0 +// Error at /home/user/main.go:164: error 1 +// Error at /home/user/main.go:164: error 2 // // Example with GORM migration (Collector is returned as an error here). -// return errorutil.NewCollector().Do( -// db.CreateTable(models.Account{}, models.Connection{}).Error, -// db.Table("account").AddUniqueIndex("account_key", "channel").Error, -// ).AsError() +// +// return errorutil.NewCollector().Do( +// db.CreateTable(models.Account{}, models.Connection{}).Error, +// db.Table("account").AddUniqueIndex("account_key", "channel").Error, +// ).AsError() type Collector struct { errors *errList } diff --git a/core/util/errorutil/handler_errors.go b/core/util/errorutil/handler_errors.go index a04a42b..d0bd50f 100644 --- a/core/util/errorutil/handler_errors.go +++ b/core/util/errorutil/handler_errors.go @@ -14,7 +14,8 @@ type ListResponse struct { // GetErrorResponse returns ErrorResponse with specified status code // Usage (with gin): -// context.JSON(GetErrorResponse(http.StatusPaymentRequired, "Not enough money")) +// +// context.JSON(GetErrorResponse(http.StatusPaymentRequired, "Not enough money")) func GetErrorResponse(statusCode int, err string) (int, interface{}) { return statusCode, Response{ Error: err, @@ -23,28 +24,32 @@ func GetErrorResponse(statusCode int, err string) (int, interface{}) { // BadRequest returns ErrorResponse with code 400 // Usage (with gin): -// context.JSON(BadRequest("invalid data")) +// +// context.JSON(BadRequest("invalid data")) func BadRequest(err string) (int, interface{}) { return GetErrorResponse(http.StatusBadRequest, err) } // Unauthorized returns ErrorResponse with code 401 // Usage (with gin): -// context.JSON(Unauthorized("invalid credentials")) +// +// context.JSON(Unauthorized("invalid credentials")) func Unauthorized(err string) (int, interface{}) { return GetErrorResponse(http.StatusUnauthorized, err) } // Forbidden returns ErrorResponse with code 403 // Usage (with gin): -// context.JSON(Forbidden("forbidden")) +// +// context.JSON(Forbidden("forbidden")) func Forbidden(err string) (int, interface{}) { return GetErrorResponse(http.StatusForbidden, err) } // InternalServerError returns ErrorResponse with code 500 // Usage (with gin): -// context.JSON(BadRequest("invalid data")) +// +// context.JSON(BadRequest("invalid data")) func InternalServerError(err string) (int, interface{}) { return GetErrorResponse(http.StatusInternalServerError, err) } diff --git a/core/util/httputil/http_client_builder.go b/core/util/httputil/http_client_builder.go index 3e1a17b..e750077 100644 --- a/core/util/httputil/http_client_builder.go +++ b/core/util/httputil/http_client_builder.go @@ -10,10 +10,10 @@ import ( "net/url" "time" + "go.uber.org/zap" + "github.com/pkg/errors" - "github.com/retailcrm/mg-transport-core/v2/core/config" - "github.com/retailcrm/mg-transport-core/v2/core/logger" ) @@ -230,12 +230,12 @@ func (b *HTTPClientBuilder) buildMocks() error { return errors.New("dialer must be built first") } - if b.mockHost != "" && b.mockPort != "" && len(b.mockedDomains) > 0 { - b.logf("Mock address is \"%s\"\n", net.JoinHostPort(b.mockHost, b.mockPort)) - b.logf("Mocked domains: ") + if b.mockHost != "" && b.mockPort != "" && len(b.mockedDomains) > 0 { // nolint:nestif + b.log("Mock address has been set", zap.String("address", net.JoinHostPort(b.mockHost, b.mockPort))) + b.log("Mocked domains: ") for _, domain := range b.mockedDomains { - b.logf(" - %s\n", domain) + b.log(fmt.Sprintf(" - %s\n", domain)) } b.httpTransport.Proxy = nil @@ -259,7 +259,7 @@ func (b *HTTPClientBuilder) buildMocks() error { addr = net.JoinHostPort(b.mockHost, b.mockPort) } - b.logf("Mocking \"%s\" with \"%s\"\n", oldAddr, addr) + b.log(fmt.Sprintf("Mocking \"%s\" with \"%s\"\n", oldAddr, addr)) } } @@ -270,13 +270,13 @@ func (b *HTTPClientBuilder) buildMocks() error { return nil } -// logf prints logs via Engine or via fmt.Printf. -func (b *HTTPClientBuilder) logf(format string, args ...interface{}) { +// log prints logs via Engine or via fmt.Println. +func (b *HTTPClientBuilder) log(msg string, args ...interface{}) { if b.logging { if b.logger != nil { - b.logger.Infof(format, args...) + b.logger.Info(msg, logger.AnyZapFields(args)...) } else { - fmt.Printf(format, args...) + fmt.Println(append([]any{msg}, args...)) } } } diff --git a/core/util/httputil/http_client_builder_test.go b/core/util/httputil/http_client_builder_test.go index 277e281..9e35422 100644 --- a/core/util/httputil/http_client_builder_test.go +++ b/core/util/httputil/http_client_builder_test.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "io/ioutil" "log" "net" "net/http" @@ -16,7 +15,6 @@ import ( "testing" "time" - "github.com/op/go-logging" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -137,14 +135,14 @@ func (t *HTTPClientBuilderTest) Test_buildMocks() { } func (t *HTTPClientBuilderTest) Test_WithLogger() { - logger := logger.NewStandard("telegram", logging.ERROR, logger.DefaultLogFormatter()) builder := NewHTTPClientBuilder() require.Nil(t.T(), builder.logger) builder.WithLogger(nil) assert.Nil(t.T(), builder.logger) - builder.WithLogger(logger) + log := logger.NewDefault("json", true) + builder.WithLogger(log) assert.NotNil(t.T(), builder.logger) } @@ -153,7 +151,7 @@ func (t *HTTPClientBuilderTest) Test_logf() { assert.Nil(t.T(), recover()) }() - t.builder.logf("test %s", "string") + t.builder.log(fmt.Sprintf("test %s", "string")) } func (t *HTTPClientBuilderTest) Test_Build() { @@ -210,6 +208,7 @@ x5porosgI2RgOTTwmiYOcYQTS2650jYydHhK16Gu2b3UKernO16mAWXNDWfvS2bk nAI2GL2ACEdOCyRvgq16AycJJYU7nYQ+t9aveefx0uhbYYIVeYub9NxmCfD3MojI saG/63vo0ng851n90DVoMRWx9n1CjEvss/vvz+jXIl9njaCtizN3WUf1NwUB -----END CERTIFICATE-----` + // nolint:gosec keyFileData := `-----BEGIN RSA PRIVATE KEY----- MIIEogIBAAKCAQEArcVIOmlAXGS4xGBIM8xPgfMALMiunU/X22w3zv+Z/T4R48UC 5402K7PcQAJe0Qmo/J1INEc319/Iw9UxsJBawtbtaYzn++DlTF7MNsWu4JmZZyXn @@ -238,9 +237,9 @@ weywTxDl/OD5ybNkZIRKsIXciFYG1VCGO2HNGN9qJcV+nJ63kyrIBauwUkuEhiN5 uf/TQPpjrGW5nxOf94qn6FzV2WSype9BcM5MD7z7rk202Fs7Zqc= -----END RSA PRIVATE KEY-----` - certFile, err := ioutil.TempFile("/tmp", "cert_") + certFile, err := os.CreateTemp("/tmp", "cert_") require.NoError(t.T(), err, "cannot create temp cert file") - keyFile, err := ioutil.TempFile("/tmp", "key_") + keyFile, err := os.CreateTemp("/tmp", "key_") require.NoError(t.T(), err, "cannot create temp key file") _, err = certFile.WriteString(certFileData) @@ -253,7 +252,7 @@ uf/TQPpjrGW5nxOf94qn6FzV2WSype9BcM5MD7z7rk202Fs7Zqc= errorutil.Collect(keyFile.Sync(), keyFile.Close()), "cannot sync and close temp key file") mux := &http.ServeMux{} - srv := &http.Server{Addr: mockServerAddr, Handler: mux} + srv := &http.Server{Addr: mockServerAddr, Handler: mux, ReadHeaderTimeout: defaultDialerTimeout} mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) _, _ = io.WriteString(w, "ok") @@ -308,7 +307,7 @@ uf/TQPpjrGW5nxOf94qn6FzV2WSype9BcM5MD7z7rk202Fs7Zqc= defer resp.Body.Close() - data, err := ioutil.ReadAll(resp.Body) + data, err := io.ReadAll(resp.Body) require.NoError(t.T(), err, "error while reading body") assert.Equal(t.T(), http.StatusCreated, resp.StatusCode, "invalid status code") diff --git a/core/util/testutil/buffer_logger.go b/core/util/testutil/buffer_logger.go index 79619d6..5659ede 100644 --- a/core/util/testutil/buffer_logger.go +++ b/core/util/testutil/buffer_logger.go @@ -1,15 +1,13 @@ package testutil import ( - "bytes" "fmt" "io" "os" - "sync" - - "github.com/op/go-logging" "github.com/retailcrm/mg-transport-core/v2/core/logger" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" ) // ReadBuffer is implemented by the BufferLogger. @@ -28,120 +26,75 @@ type BufferedLogger interface { } // BufferLogger is an implementation of the BufferedLogger. +// +// BufferLogger can be used in tests to match specific log messages. It uses JSON by default (hardcoded for now). +// It implements fmt.Stringer and provides an adapter to the underlying buffer, which means it can also return +// Bytes(), can be used like io.Reader and can be cleaned using Reset() method. +// +// Usage: +// +// log := NewBufferedLogger() +// // Some other code that works with logger. +// fmt.Println(log.String()) type BufferLogger struct { - buf bytes.Buffer - rw sync.RWMutex + logger.Default + buf LockableBuffer } // NewBufferedLogger returns new BufferedLogger instance. func NewBufferedLogger() BufferedLogger { - return &BufferLogger{} + bl := &BufferLogger{} + bl.Logger = zap.New( + zapcore.NewCore( + logger.NewJSONWithContextEncoder( + logger.EncoderConfigJSON()), zap.CombineWriteSyncers(os.Stdout, os.Stderr, &bl.buf), zapcore.DebugLevel)) + return bl +} + +func (l *BufferLogger) With(fields ...zapcore.Field) logger.Logger { + return &BufferLogger{ + Default: logger.Default{ + Logger: l.Logger.With(fields...), + }, + } +} + +func (l *BufferLogger) WithLazy(fields ...zapcore.Field) logger.Logger { + return &BufferLogger{ + Default: logger.Default{ + Logger: l.Logger.WithLazy(fields...), + }, + } +} + +func (l *BufferLogger) ForHandler(handler any) logger.Logger { + return l.WithLazy(zap.Any(logger.HandlerAttr, handler)) +} + +func (l *BufferLogger) ForConnection(conn any) logger.Logger { + return l.WithLazy(zap.Any(logger.ConnectionAttr, conn)) +} + +func (l *BufferLogger) ForAccount(acc any) logger.Logger { + return l.WithLazy(zap.Any(logger.AccountAttr, acc)) } // Read bytes from the logger buffer. io.Reader implementation. func (l *BufferLogger) Read(p []byte) (n int, err error) { - defer l.rw.RUnlock() - l.rw.RLock() return l.buf.Read(p) } // String contents of the logger buffer. fmt.Stringer implementation. func (l *BufferLogger) String() string { - defer l.rw.RUnlock() - l.rw.RLock() return l.buf.String() } // Bytes is a shorthand for the underlying bytes.Buffer method. Returns byte slice with the buffer contents. func (l *BufferLogger) Bytes() []byte { - defer l.rw.RUnlock() - l.rw.RLock() return l.buf.Bytes() } // Reset is a shorthand for the underlying bytes.Buffer method. It will reset buffer contents. func (l *BufferLogger) Reset() { - defer l.rw.Unlock() - l.rw.Lock() l.buf.Reset() } - -func (l *BufferLogger) write(level logging.Level, args ...interface{}) { - defer l.rw.Unlock() - l.rw.Lock() - l.buf.WriteString(fmt.Sprintln(append([]interface{}{level.String(), "=>"}, args...)...)) -} - -func (l *BufferLogger) writef(level logging.Level, format string, args ...interface{}) { - defer l.rw.Unlock() - l.rw.Lock() - l.buf.WriteString(fmt.Sprintf(level.String()+" => "+format, args...)) -} - -func (l *BufferLogger) Fatal(args ...interface{}) { - l.write(logging.CRITICAL, args...) - os.Exit(1) -} - -func (l *BufferLogger) Fatalf(format string, args ...interface{}) { - l.writef(logging.CRITICAL, format, args...) - os.Exit(1) -} - -func (l *BufferLogger) Panic(args ...interface{}) { - l.write(logging.CRITICAL, args...) - panic(fmt.Sprint(args...)) -} - -func (l *BufferLogger) Panicf(format string, args ...interface{}) { - l.writef(logging.CRITICAL, format, args...) - panic(fmt.Sprintf(format, args...)) -} - -func (l *BufferLogger) Critical(args ...interface{}) { - l.write(logging.CRITICAL, args...) -} - -func (l *BufferLogger) Criticalf(format string, args ...interface{}) { - l.writef(logging.CRITICAL, format, args...) -} - -func (l *BufferLogger) Error(args ...interface{}) { - l.write(logging.ERROR, args...) -} - -func (l *BufferLogger) Errorf(format string, args ...interface{}) { - l.writef(logging.ERROR, format, args...) -} - -func (l *BufferLogger) Warning(args ...interface{}) { - l.write(logging.WARNING, args...) -} - -func (l *BufferLogger) Warningf(format string, args ...interface{}) { - l.writef(logging.WARNING, format, args...) -} - -func (l *BufferLogger) Notice(args ...interface{}) { - l.write(logging.NOTICE, args...) -} - -func (l *BufferLogger) Noticef(format string, args ...interface{}) { - l.writef(logging.NOTICE, format, args...) -} - -func (l *BufferLogger) Info(args ...interface{}) { - l.write(logging.INFO, args...) -} - -func (l *BufferLogger) Infof(format string, args ...interface{}) { - l.writef(logging.INFO, format, args...) -} - -func (l *BufferLogger) Debug(args ...interface{}) { - l.write(logging.DEBUG, args...) -} - -func (l *BufferLogger) Debugf(format string, args ...interface{}) { - l.writef(logging.DEBUG, format, args...) -} diff --git a/core/util/testutil/buffer_logger_test.go b/core/util/testutil/buffer_logger_test.go index 8a76d2a..8d0771d 100644 --- a/core/util/testutil/buffer_logger_test.go +++ b/core/util/testutil/buffer_logger_test.go @@ -4,7 +4,6 @@ import ( "io" "testing" - "github.com/op/go-logging" "github.com/stretchr/testify/suite" ) @@ -30,17 +29,20 @@ func (t *BufferLoggerTest) Test_Read() { data, err := io.ReadAll(t.logger) t.Require().NoError(err) - t.Assert().Equal([]byte(logging.DEBUG.String()+" => test\n"), data) + t.Assert().Contains(string(data), "\"level_name\":\"DEBUG\"") + t.Assert().Contains(string(data), "\"message\":\"test\"") } func (t *BufferLoggerTest) Test_Bytes() { t.logger.Debug("test") - t.Assert().Equal([]byte(logging.DEBUG.String()+" => test\n"), t.logger.Bytes()) + t.Assert().Contains(string(t.logger.Bytes()), "\"level_name\":\"DEBUG\"") + t.Assert().Contains(string(t.logger.Bytes()), "\"message\":\"test\"") } func (t *BufferLoggerTest) Test_String() { t.logger.Debug("test") - t.Assert().Equal(logging.DEBUG.String()+" => test\n", t.logger.String()) + t.Assert().Contains(t.logger.String(), "\"level_name\":\"DEBUG\"") + t.Assert().Contains(t.logger.String(), "\"message\":\"test\"") } func (t *BufferLoggerTest) TestRace() { diff --git a/core/util/testutil/gock.go b/core/util/testutil/gock.go index 130daf3..7505ace 100644 --- a/core/util/testutil/gock.go +++ b/core/util/testutil/gock.go @@ -46,7 +46,7 @@ func printRequestData(t UnmatchedRequestsTestingT, r *http.Request) { } } - if r.Body == nil { + if r.Body == nil { // nolint:nestif t.Log("No body is present.") } else { data, err := io.ReadAll(r.Body) diff --git a/core/util/testutil/gorm.go b/core/util/testutil/gorm.go index b53217f..0c01e11 100644 --- a/core/util/testutil/gorm.go +++ b/core/util/testutil/gorm.go @@ -11,11 +11,11 @@ import ( // remove all the entities created after the hook was set up. // You can use it like this: // -// func TestSomething(t *testing.T){ -// db, _ := gorm.Open(...) -// cleaner := DeleteCreatedEntities(db) -// defer cleaner() -// }. +// func TestSomething(t *testing.T){ +// db, _ := gorm.Open(...) +// cleaner := DeleteCreatedEntities(db) +// defer cleaner() +// }. func DeleteCreatedEntities(db *gorm.DB) func() { // nolint type entity struct { key interface{} diff --git a/core/util/testutil/json_record_scanner.go b/core/util/testutil/json_record_scanner.go new file mode 100644 index 0000000..6887fc2 --- /dev/null +++ b/core/util/testutil/json_record_scanner.go @@ -0,0 +1,52 @@ +package testutil + +import ( + "bufio" + "encoding/json" + "io" + + "github.com/guregu/null/v5" +) + +type LogRecord struct { + LevelName string `json:"level_name"` + DateTime null.Time `json:"datetime"` + Caller string `json:"caller"` + Message string `json:"message"` + Handler string `json:"handler,omitempty"` + Connection string `json:"connection,omitempty"` + Account string `json:"account,omitempty"` + Context map[string]interface{} `json:"context,omitempty"` +} + +type JSONRecordScanner struct { + r *bufio.Scanner + e LogRecord +} + +func NewJSONRecordScanner(entryProvider io.Reader) *JSONRecordScanner { + return &JSONRecordScanner{r: bufio.NewScanner(entryProvider)} +} + +func (s *JSONRecordScanner) Scan() error { + if s.r.Scan() { + return json.Unmarshal(s.r.Bytes(), &s.e) + } + return io.EOF +} + +func (s *JSONRecordScanner) ScanAll() ([]LogRecord, error) { + var entries []LogRecord + for s.r.Scan() { + entry := LogRecord{} + if err := json.Unmarshal(s.r.Bytes(), &entry); err != nil { + return entries, err + } + entries = append(entries, entry) + } + return entries, nil +} + +func (s *JSONRecordScanner) Entry() LogRecord { + return s.e +} diff --git a/core/util/testutil/json_record_scanner_test.go b/core/util/testutil/json_record_scanner_test.go new file mode 100644 index 0000000..d8fd64d --- /dev/null +++ b/core/util/testutil/json_record_scanner_test.go @@ -0,0 +1,108 @@ +package testutil + +import ( + "bytes" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/suite" +) + +type JSONRecordScannerTest struct { + suite.Suite +} + +func TestJSONRecordScanner(t *testing.T) { + suite.Run(t, new(JSONRecordScannerTest)) +} + +func (t *JSONRecordScannerTest) new(lines []string) *JSONRecordScanner { + return NewJSONRecordScanner(bytes.NewReader([]byte(strings.Join(lines, "\n")))) +} + +func (t *JSONRecordScannerTest) newPredefined() *JSONRecordScanner { + return t.new([]string{strings.ReplaceAll(`{ + "level_name": "ERROR", + "datetime": "2024-06-07T13:49:17+03:00", + "caller": "handlers/account_middleware.go:147", + "message": "Cannot add account", + "handler": "handlers.addAccount", + "connection": "https://fake-uri.retailcrm.pro", + "account": "@username", + "context": { + "body": "[]string{\"integration_read\", \"integration_write\"}", + "statusCode": 500 + } +}`, "\n", "")}) +} + +func (t *JSONRecordScannerTest) assertPredefined(record LogRecord) { + ts, err := time.Parse(time.RFC3339, "2024-06-07T13:49:17+03:00") + t.Require().NoError(err) + t.Assert().True(record.DateTime.Valid) + t.Assert().Equal(ts, record.DateTime.Time) + t.Assert().Equal("ERROR", record.LevelName) + t.Assert().Equal("handlers/account_middleware.go:147", record.Caller) + t.Assert().Equal("Cannot add account", record.Message) + t.Assert().Equal("handlers.addAccount", record.Handler) + t.Assert().Equal("https://fake-uri.retailcrm.pro", record.Connection) + t.Assert().Equal("@username", record.Account) + t.Assert().Equal("[]string{\"integration_read\", \"integration_write\"}", record.Context["body"]) + t.Assert().Equal(float64(500), record.Context["statusCode"]) +} + +func (t *JSONRecordScannerTest) TestScan_NotJSON() { + rs := t.new([]string{"this is", "not json"}) + t.Assert().Error(rs.Scan()) +} + +func (t *JSONRecordScannerTest) TestScan_PartialJSON() { + rs := t.new([]string{"{}", "not json"}) + t.Assert().NoError(rs.Scan()) + t.Assert().Error(rs.Scan()) +} + +func (t *JSONRecordScannerTest) TestScan_JSON() { + rs := t.new([]string{"{}", "{}"}) + t.Assert().NoError(rs.Scan()) + t.Assert().NoError(rs.Scan()) + t.Assert().ErrorIs(rs.Scan(), io.EOF) +} + +func (t *JSONRecordScannerTest) TestScan_JSONRecord() { + rs := t.newPredefined() + t.Assert().NoError(rs.Scan()) + t.Assert().ErrorIs(rs.Scan(), io.EOF) + t.assertPredefined(rs.Entry()) +} + +func (t *JSONRecordScannerTest) TestScanAll_NotJSON() { + rs := t.new([]string{"this is", "not json"}) + records, err := rs.ScanAll() + t.Assert().Error(err) + t.Assert().Empty(records) +} + +func (t *JSONRecordScannerTest) TestScanAll_PartialJSON() { + rs := t.new([]string{"{}", "not json"}) + records, err := rs.ScanAll() + t.Assert().Error(err) + t.Assert().Len(records, 1) +} + +func (t *JSONRecordScannerTest) TestScanAll_JSON() { + rs := t.new([]string{"{}", "{}"}) + records, err := rs.ScanAll() + t.Assert().NoError(err) + t.Assert().Len(records, 2) +} + +func (t *JSONRecordScannerTest) TestScanAll_JSONRecord() { + rs := t.newPredefined() + records, err := rs.ScanAll() + t.Assert().NoError(err) + t.Assert().Len(records, 1) + t.assertPredefined(records[0]) +} diff --git a/core/util/testutil/lockable_buffer.go b/core/util/testutil/lockable_buffer.go new file mode 100644 index 0000000..8636a9a --- /dev/null +++ b/core/util/testutil/lockable_buffer.go @@ -0,0 +1,155 @@ +package testutil + +import ( + "bytes" + "io" + "sync" +) + +type LockableBuffer struct { + buf bytes.Buffer + rw sync.RWMutex +} + +func (b *LockableBuffer) Bytes() []byte { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Bytes() +} + +func (b *LockableBuffer) AvailableBuffer() []byte { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.AvailableBuffer() +} + +func (b *LockableBuffer) String() string { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.String() +} + +func (b *LockableBuffer) Len() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Len() +} + +func (b *LockableBuffer) Cap() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Cap() +} + +func (b *LockableBuffer) Available() int { + defer b.rw.RUnlock() + b.rw.RLock() + return b.buf.Available() +} + +func (b *LockableBuffer) Truncate(n int) { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Truncate(n) +} + +func (b *LockableBuffer) Reset() { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Reset() +} + +func (b *LockableBuffer) Grow(n int) { + defer b.rw.Unlock() + b.rw.Lock() + b.buf.Grow(n) +} + +func (b *LockableBuffer) Write(p []byte) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Write(p) +} + +func (b *LockableBuffer) WriteString(s string) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteString(s) +} + +func (b *LockableBuffer) ReadFrom(r io.Reader) (n int64, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadFrom(r) +} + +func (b *LockableBuffer) WriteTo(w io.Writer) (n int64, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteTo(w) +} + +func (b *LockableBuffer) WriteByte(c byte) error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteByte(c) +} + +func (b *LockableBuffer) WriteRune(r rune) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.WriteRune(r) +} + +func (b *LockableBuffer) Read(p []byte) (n int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Read(p) +} + +func (b *LockableBuffer) Next(n int) []byte { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.Next(n) +} + +func (b *LockableBuffer) ReadByte() (byte, error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadByte() +} + +func (b *LockableBuffer) ReadRune() (r rune, size int, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadRune() +} + +func (b *LockableBuffer) UnreadRune() error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.UnreadRune() +} + +func (b *LockableBuffer) UnreadByte() error { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.UnreadByte() +} + +func (b *LockableBuffer) ReadBytes(delim byte) (line []byte, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadBytes(delim) +} + +func (b *LockableBuffer) ReadString(delim byte) (line string, err error) { + defer b.rw.Unlock() + b.rw.Lock() + return b.buf.ReadString(delim) +} + +// Sync is a no-op. +func (b *LockableBuffer) Sync() error { + return nil +} diff --git a/core/util/testutil/translations_extractor.go b/core/util/testutil/translations_extractor.go index 4abb1a8..bf63cbe 100644 --- a/core/util/testutil/translations_extractor.go +++ b/core/util/testutil/translations_extractor.go @@ -3,7 +3,6 @@ package testutil import ( "errors" "io/fs" - "io/ioutil" "os" "path/filepath" "sort" @@ -63,8 +62,8 @@ func (t *TranslationsExtractor) loadYAMLFile(fileName string) (map[string]interf err error ) - if info, err = os.Stat(fileName); err == nil { - if !info.IsDir() { + if info, err = os.Stat(fileName); err == nil { // nolint:nestif + if !info.IsDir() { // nolint:nestif var ( path string source []byte @@ -75,7 +74,7 @@ func (t *TranslationsExtractor) loadYAMLFile(fileName string) (map[string]interf return dataMap, err } - if source, err = ioutil.ReadFile(path); err != nil { + if source, err = os.ReadFile(path); err != nil { return dataMap, err } diff --git a/core/util/testutil/translations_extractor_test.go b/core/util/testutil/translations_extractor_test.go index 11de554..43552be 100644 --- a/core/util/testutil/translations_extractor_test.go +++ b/core/util/testutil/translations_extractor_test.go @@ -1,7 +1,6 @@ package testutil import ( - "io/ioutil" "os" "reflect" "sort" @@ -40,7 +39,7 @@ func (t *TranslationsExtractorTest) SetupSuite() { data, _ := yaml.Marshal(translation) // It's not regular temporary file. Little hack in order to test translations extractor. // nolint:gosec - errWrite := ioutil.WriteFile("/tmp/translate.en.yml", data, os.ModePerm) + errWrite := os.WriteFile("/tmp/translate.en.yml", data, os.ModePerm) require.NoError(t.T(), errWrite) t.extractor = NewTranslationsExtractor("translate.{}.yml") diff --git a/core/util/utils.go b/core/util/utils.go index 2ff8f5f..71bbac6 100644 --- a/core/util/utils.go +++ b/core/util/utils.go @@ -1,11 +1,13 @@ package util import ( + "bytes" // nolint:gosec "crypto/sha1" "crypto/sha256" "encoding/json" "fmt" + "io" "net/http" "regexp" "strings" @@ -16,11 +18,12 @@ import ( "github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/s3/s3manager" + "github.com/gin-gonic/gin" retailcrm "github.com/retailcrm/api-client-go/v2" - v1 "github.com/retailcrm/mg-transport-api-client-go/v1" - "github.com/retailcrm/mg-transport-core/v2/core/config" + v1 "github.com/retailcrm/mg-transport-api-client-go/v1" + "github.com/retailcrm/mg-transport-core/v2/core/logger" "github.com/retailcrm/mg-transport-core/v2/core/util/errorutil" @@ -132,7 +135,7 @@ func (u *Utils) GenerateToken() string { func (u *Utils) GetAPIClient( url, key string, scopes []string, credentials ...[]string) (*retailcrm.Client, int, error) { client := retailcrm.New(url, key). - WithLogger(retailcrm.DebugLoggerAdapter(u.Logger)) + WithLogger(logger.APIClientAdapter(u.Logger)) client.Debug = u.IsDebug cr, status, err := client.APICredentials() @@ -142,12 +145,12 @@ func (u *Utils) GetAPIClient( if res := u.checkScopes(cr.Scopes, scopes); len(res) != 0 { if len(credentials) == 0 || len(cr.Scopes) > 0 { - u.Logger.Error(url, status, res) + u.Logger.Error(url, logger.HTTPStatusCode(status), logger.Body(res)) return nil, http.StatusBadRequest, errorutil.NewInsufficientScopesErr(res) } if res := u.checkScopes(cr.Credentials, credentials[0]); len(res) != 0 { - u.Logger.Error(url, status, res) + u.Logger.Error(url, logger.HTTPStatusCode(status), logger.Body(res)) return nil, http.StatusBadRequest, errorutil.NewInsufficientScopesErr(res) } } @@ -281,3 +284,15 @@ func GetCurrencySymbol(code string) string { func FormatCurrencyValue(value float32) string { return fmt.Sprintf("%.2f", value) } + +// BindJSONWithRaw will perform usual ShouldBindJSON and will return the original body data. +func BindJSONWithRaw(c *gin.Context, obj any) ([]byte, error) { + closer := c.Request.Body + defer func() { _ = closer.Close() }() + data, err := io.ReadAll(closer) + if err != nil { + return []byte{}, err + } + c.Request.Body = io.NopCloser(bytes.NewReader(data)) + return data, c.ShouldBindJSON(obj) +} diff --git a/core/util/utils_test.go b/core/util/utils_test.go index ce7a015..cc041e9 100644 --- a/core/util/utils_test.go +++ b/core/util/utils_test.go @@ -9,13 +9,14 @@ import ( "time" "github.com/h2non/gock" - "github.com/op/go-logging" + retailcrm "github.com/retailcrm/api-client-go/v2" - v1 "github.com/retailcrm/mg-transport-api-client-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + v1 "github.com/retailcrm/mg-transport-api-client-go/v1" + "github.com/retailcrm/mg-transport-core/v2/core/config" "github.com/retailcrm/mg-transport-core/v2/core/logger" @@ -38,7 +39,7 @@ func mgClient() *v1.MgClient { } func (u *UtilsTest) SetupSuite() { - logger := logger.NewStandard("code", logging.DEBUG, logger.DefaultLogFormatter()) + logger := logger.NewDefault("json", true) awsConfig := config.AWS{ AccessKeyID: "access key id (will be removed)", SecretAccessKey: "secret access key", diff --git a/go.mod b/go.mod index 0a8c1e7..4c29cc8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/retailcrm/mg-transport-core/v2 -go 1.18 +go 1.22 + +toolchain go1.22.0 require ( github.com/DATA-DOG/go-sqlmock v1.3.3 @@ -17,6 +19,7 @@ require ( github.com/gomarkdown/markdown v0.0.0-20221013030248-663e2500819c github.com/gorilla/securecookie v1.1.1 github.com/gorilla/sessions v1.2.0 + github.com/guregu/null/v5 v5.0.0 github.com/h2non/gock v1.2.0 github.com/jessevdk/go-flags v1.4.0 github.com/jinzhu/gorm v1.9.11 @@ -24,10 +27,11 @@ require ( github.com/op/go-logging v0.0.0-20160315200505-970db520ece7 github.com/pkg/errors v0.9.1 github.com/retailcrm/api-client-go/v2 v2.1.3 - github.com/retailcrm/mg-transport-api-client-go v1.1.32 + github.com/retailcrm/mg-transport-api-client-go v1.3.4 github.com/retailcrm/zabbix-metrics-collector v1.0.0 github.com/stretchr/testify v1.8.3 go.uber.org/atomic v1.10.0 + go.uber.org/zap v1.26.0 golang.org/x/text v0.14.0 gopkg.in/gormigrate.v1 v1.6.0 gopkg.in/yaml.v2 v2.4.0 @@ -77,6 +81,7 @@ require ( github.com/stretchr/objx v0.5.0 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.2.11 // indirect + go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/crypto v0.21.0 // indirect golang.org/x/net v0.23.0 // indirect diff --git a/go.sum b/go.sum index 6f7ec25..5cf0ba1 100644 --- a/go.sum +++ b/go.sum @@ -157,6 +157,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= @@ -237,6 +238,8 @@ github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+ github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/guregu/null/v5 v5.0.0 h1:PRxjqyOekS11W+w/7Vfz6jgJE/BCwELWtgvOJzddimw= +github.com/guregu/null/v5 v5.0.0/go.mod h1:SjupzNy+sCPtwQTKWhUCqjhVCO69hpsl2QsZrWHjlwU= github.com/h2non/gock v1.2.0 h1:K6ol8rfrRkUOefooBC8elXoaNGYkpp7y2qcxGG6BzUE= github.com/h2non/gock v1.2.0/go.mod h1:tNhoxHYW2W42cYkYb1WqzdbYIieALC99kpYr7rH/BQk= github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw= @@ -296,8 +299,9 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/labstack/echo/v4 v4.5.0/go.mod h1:czIriw4a0C1dFun+ObrXp7ok03xON0N1awStJ6ArI7Y= github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k= github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q= @@ -376,8 +380,8 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/retailcrm/api-client-go/v2 v2.1.3 h1:AVcp9oeSOm6+3EWXCgdQs+XE3PTjzCKKB//MUAe0Zb0= github.com/retailcrm/api-client-go/v2 v2.1.3/go.mod h1:1yTZl9+gd3+/k0kAJe7sYvC+mL4fqMwIwtnSgSWZlkQ= -github.com/retailcrm/mg-transport-api-client-go v1.1.32 h1:IBPltSoD5q2PPZJbNC/prK5F9rEVPXVx/ZzDpi7HKhs= -github.com/retailcrm/mg-transport-api-client-go v1.1.32/go.mod h1:AWV6BueE28/6SCoyfKURTo4lF0oXYoOKmHTzehd5vAI= +github.com/retailcrm/mg-transport-api-client-go v1.3.4 h1:HIn4eorABNfudn7hr5Rd6XYC/ieDTqCkaq6wv0AFTBE= +github.com/retailcrm/mg-transport-api-client-go v1.3.4/go.mod h1:gDe/tj7t3Hr/uwIFSBVgGAmP85PoLajVl1A+skBo1Ro= github.com/retailcrm/zabbix-metrics-collector v1.0.0 h1:ju3rhpgVoiKII6oXEJEf2eoJy5bNcYAmOPRp1oPWDmA= github.com/retailcrm/zabbix-metrics-collector v1.0.0/go.mod h1:3Orc+gfSg1tXj89QNvOn22t0cO1i2whR/4NJUGonWJA= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= @@ -441,6 +445,12 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= +go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=