diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index a32fa97..dc763e1 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -18,9 +18,9 @@ jobs: - uses: actions/checkout@v3 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.20.6' + go-version: '1.21' - name: Vet run: go vet ./... diff --git a/README.md b/README.md index 4955b6e..4fd4035 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,49 @@ # slacklogger -This client is used to send messages using a slack webhook url. +This lib can be used to easily send messages to slack channels via the Slack Web API. ## Installation ```shell -go get github.com/Clarilab/slacklogger/v2 +go get github.com/Clarilab/slacklogger/v3 ``` ## Importing ```go -import "github.com/Clarilab/slacklogger/v2" +import "github.com/Clarilab/slacklogger/v3" ``` -## Examples - -### Logging with an instanced logger +## Requirements +A [**Slack-App**](https://api.slack.com/docs/apps) installed to the desired **Slack-Workspace** with correct permissions/scopes set. -```go -webhookURL := "https://hooks.slack.com/..." -environment := "development" -isDebug := false +Minimum required permission/scope is: **chat:write** -slacker := slacklogger.NewSlackLogger(webhookURL, environment, isDebug) +For more information checkout the [Slack Apps Quickstart Guide](https://api.slack.com/quickstart). -slacker.Log("Something weird") +## Authorization +When using a proxy like [**Slack-Proxy**](https://github.com/fortio/slack-proxy), the authorization needs to be setup in the proxy and is not needed here. -// this will result in: env=development Something weird -``` +Otherwise the [**Slack-Apps**](https://api.slack.com/docs/apps) **OAuth-Token** needs to be provided via the WithAuthorization() option. -### Logging without an instanced logger +## Examples ```go -webhookURL := "https://hooks.slack.com/..." -environment := "" -isDebug := false -message := "Hello World!" +url := "https://.slack.com/api/chat.postMessage" +token := "slack-token" +channel := "log-channel" +environment := "development" + -slacklogger.LogWithURL(message, webhookURL, environment, isDebug) +slacker := slacklogger.NewSlackLogger(url, channel, slacklogger.WithEnvironment(environment), slacklogger.WithAuthorization(token)) + +slacker.Log("Something weird") -// this will result in: Hello World! +// this will result in: +// environment: development +// +// Something weird ``` -If isDebug is set to true, it will print to stdout instead. +If the UseDebug option is used, it will log to stdout instead using the log/slog logger. diff --git a/client.go b/client.go index 97cc3af..67f1a46 100644 --- a/client.go +++ b/client.go @@ -2,84 +2,58 @@ package slacklogger import ( "bytes" + "context" "encoding/json" "fmt" "net/http" ) -type Field struct { - Title string `json:"title"` - Value string `json:"value"` - Short bool `json:"short"` -} - -type Action struct { - Type string `json:"type"` - Text string `json:"text"` - Url string `json:"url"` - Style string `json:"style"` -} - -type Attachment struct { - Fallback *string `json:"fallback"` - Color *string `json:"color"` - PreText *string `json:"pretext"` - AuthorName *string `json:"author_name"` - AuthorLink *string `json:"author_link"` - AuthorIcon *string `json:"author_icon"` - Title *string `json:"title"` - TitleLink *string `json:"title_link"` - Text *string `json:"text"` - ImageUrl *string `json:"image_url"` - Fields []*Field `json:"fields"` - Footer *string `json:"footer"` - FooterIcon *string `json:"footer_icon"` - Timestamp *int64 `json:"ts"` - MarkdownIn *[]string `json:"mrkdwn_in"` - Actions []*Action `json:"actions"` - CallbackID *string `json:"callback_id"` - ThumbnailUrl *string `json:"thumb_url"` -} - +// Payload is the payload send to slack. type Payload struct { - Parse string `json:"parse,omitempty"` - Username string `json:"username,omitempty"` - IconUrl string `json:"icon_url,omitempty"` - IconEmoji string `json:"icon_emoji,omitempty"` - Channel string `json:"channel,omitempty"` - Text string `json:"text,omitempty"` - LinkNames string `json:"link_names,omitempty"` - Attachments []Attachment `json:"attachments,omitempty"` - UnfurlLinks bool `json:"unfurl_links,omitempty"` - UnfurlMedia bool `json:"unfurl_media,omitempty"` - Markdown bool `json:"mrkdwn,omitempty"` -} - -func (attachment *Attachment) AddField(field Field) *Attachment { - attachment.Fields = append(attachment.Fields, &field) - return attachment + Channel string `json:"channel"` // required + Text string `json:"text"` + AsUser bool `json:"as_user"` + Username string `json:"username,omitempty"` + IconURL string `json:"icon_url,omitempty"` + IconEmoji string `json:"icon_emoji,omitempty"` + ThreadTS string `json:"thread_ts,omitempty"` + Parse string `json:"parse,omitempty"` + LinkNames bool `json:"link_names,omitempty"` + Blocks json.RawMessage `json:"blocks,omitempty"` // JSON serialized array of blocks } -func (attachment *Attachment) AddAction(action Action) *Attachment { - attachment.Actions = append(attachment.Actions, &action) - return attachment -} +func (l *SlackLogger) send(ctx context.Context, payload *Payload) error { + const ( + errMessage = "failed to send to slack: %w" + headerContentType = "Content-Type" + headerAuthorization = "Authorization" + mimeJSON = "application/json; charset=utf-8" + tokenPrefix = "Bearer " + ) -var ErrFailedToMarshalJSON = fmt.Errorf("failed to marshal payload") + payloadBytes, err := json.Marshal(&payload) + if err != nil { + return fmt.Errorf(errMessage, err) + } -func Send(webhookUrl string, payload Payload) error { - jsonBytes, err := json.Marshal(&payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, l.url, bytes.NewBuffer(payloadBytes)) if err != nil { - return ErrFailedToMarshalJSON + return fmt.Errorf(errMessage, err) + } + + req.Header.Set(headerContentType, mimeJSON) + + if l.token != "" { + req.Header.Set(headerAuthorization, tokenPrefix+l.token) } - resp, err := http.Post(webhookUrl, "application/json", bytes.NewBuffer(jsonBytes)) + resp, err := l.client.Do(req) if err != nil { - return err + return fmt.Errorf(errMessage, err) } - if resp.StatusCode >= 400 { - return error(fmt.Errorf("Error sending msg. Status: %v", resp.Status)) + if resp.StatusCode >= http.StatusBadRequest { + return fmt.Errorf(errMessage, newSlackError(resp.Status)) } return nil diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..50e1d7d --- /dev/null +++ b/errors.go @@ -0,0 +1,15 @@ +package slacklogger + +func newSlackError(status string) error { + return &SlackError{status: status} +} + +// SlackError occurs when slack responded with an error status code. +type SlackError struct { + status string +} + +// Error implements the error interface. +func (e *SlackError) Error() string { + return "error sending to slack. status: " + e.status +} diff --git a/go.mod b/go.mod index f8d3be7..41320d7 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/Clarilab/slacklogger/v2 +module github.com/Clarilab/slacklogger/v3 -go 1.20 +go 1.21 diff --git a/options.go b/options.go new file mode 100644 index 0000000..cf7f04d --- /dev/null +++ b/options.go @@ -0,0 +1,28 @@ +package slacklogger + +// Option is an option func for creating a new SlackLogger. +type Option func(*SlackLogger) + +// WithAuthorization sets the authorization token. +func WithAuthorization(token string) Option { + return func(sl *SlackLogger) { + sl.token = token + } +} + +// UseDebug is an option for creating a new SlackLogger. +// When used the logger is NOT logging to slack, instead its logging to stdout using log/slog. +// Can be useful for tests. +func UseDebug() Option { + return func(sl *SlackLogger) { + sl.isDebug = true + } +} + +// WithEnvironment is an option for creating a new SlackLogger. +// When used the given environment is added before the message. +func WithEnvironment(env string) Option { + return func(sl *SlackLogger) { + sl.environment = env + } +} diff --git a/slacklogger.go b/slacklogger.go index dda6217..d752ec5 100644 --- a/slacklogger.go +++ b/slacklogger.go @@ -1,47 +1,87 @@ package slacklogger import ( + "context" "fmt" + "net/http" + + "log/slog" ) +// SlackLogger provides functionality to send messages to slack. type SlackLogger struct { - webhookURL string + client *http.Client + url string + token string + channel string environment string isDebug bool } -// NewSlackLogger returns a new instance of SlackLogger -func NewSlackLogger(webhookURL, environment string, isDebug bool) *SlackLogger { - return &SlackLogger{ - webhookURL: webhookURL, - environment: environment, - isDebug: isDebug, +// NewSlackLogger creates a new instance of SlackLogger. +func NewSlackLogger(url, channel string, options ...Option) *SlackLogger { + sl := SlackLogger{ + client: new(http.Client), + url: url, + channel: channel, + } + + for i := range options { + options[i](&sl) } + + return &sl } -func (logger *SlackLogger) Log(message string) { - LogWithURL(message, logger.webhookURL, logger.environment, logger.isDebug) +// Log sends a simple message to slack. +func (l *SlackLogger) Log(message string) { + l.log(context.Background(), message) } -func (logger *SlackLogger) Write(message []byte) (int, error) { - logger.Log(string(message)) +// LogContext sends a simple message to slack with the given context.Context. +func (l *SlackLogger) LogContext(ctx context.Context, message string) { + l.log(ctx, message) +} + +// Write implements the io.Writer interface. +func (l *SlackLogger) Write(message []byte) (int, error) { + l.Log(string(message)) return len(message), nil } -func LogWithURL(message, url, env string, isDebug bool) { - if env != "" { - message = fmt.Sprintf("env=%s, %s", env, message) +// Send sends the given payload to slack. +func (l *SlackLogger) Send(ctx context.Context, payload *Payload) { + const errMessage = "error while logging to slack" + + if err := l.send(ctx, payload); err != nil { + slog.Error(errMessage, ErrorAttr(err)) + } +} + +func (l *SlackLogger) log(ctx context.Context, message string) { + const ( + errMessage = "error while logging to slack" + envPrefixFormat = "environment: %s\n\n, %s" + debugMessage = "pretending to log to slack" + ) + + if l.environment != "" { + message = fmt.Sprintf(envPrefixFormat, l.environment, message) } - if isDebug { - fmt.Println("Debug: Logging to Slack: " + message) + if l.isDebug { + slog.Debug(debugMessage, MessageAttr(message)) return } - err := Send(url, Payload{Text: message}) - if err != nil { - fmt.Printf("Error while logging to Slack: %s\nOriginal message was: %s\n", err, message) + payload := Payload{ + Channel: l.channel, + Text: message, + } + + if err := l.send(ctx, &payload); err != nil { + slog.Error(errMessage, ErrorAttr(err)) } } diff --git a/slacklogger_test.go b/slacklogger_test.go index 5180d37..63d7824 100644 --- a/slacklogger_test.go +++ b/slacklogger_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/Clarilab/slacklogger/v2" + "github.com/Clarilab/slacklogger/v3" ) func Test_Log(t *testing.T) { @@ -12,11 +12,16 @@ func Test_Log(t *testing.T) { t.Skip() } - webhookURL := os.Getenv("WEBHOOK_URL") - if webhookURL == "" { - t.Fatal("webhook url is not set") + url := os.Getenv("SLACK_URL") + if url == "" { + t.Fatal("slack url is not set") } - logger := slacklogger.NewSlackLogger(webhookURL, "dev", false) + token := os.Getenv("SLACK_TOKEN") + if token == "" { + t.Fatal("slack token is not set") + } + + logger := slacklogger.NewSlackLogger(url, "logs-test", slacklogger.WithAuthorization(token)) logger.Log("test message") } diff --git a/slog.go b/slog.go new file mode 100644 index 0000000..9d3740c --- /dev/null +++ b/slog.go @@ -0,0 +1,18 @@ +package slacklogger + +import "log/slog" + +const ( + keyError = "error" + keyMessage = "message" +) + +// ErrorAttr creates an slog.Attr for error. +func ErrorAttr(err error) slog.Attr { + return slog.Any(keyError, err) +} + +// MessageAttr creates an slog.Attr for message. +func MessageAttr(msg string) slog.Attr { + return slog.String(keyMessage, msg) +}