package http import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "github.com/nikoksr/notify" ) type ( // PreSendHookFn defines a function signature for a pre-send hook. PreSendHookFn func(req *http.Request) error // PostSendHookFn defines a function signature for a post-send hook. PostSendHookFn func(req *http.Request, resp *http.Response) error // BuildPayloadFn defines a function signature for a function that builds a payload. BuildPayloadFn func(subject, message string) (payload any) // Serializer is used to serialize the payload to a byte slice. Serializer interface { Marshal(contentType string, payload any) (payloadRaw []byte, err error) } // Webhook represents a single webhook receiver. It contains all the information needed to send a valid request to // the receiver. The BuildPayload function is used to build the payload that will be sent to the receiver from the // given subject and message. Webhook struct { ContentType string Header http.Header Method string URL string BuildPayload BuildPayloadFn } // Service is the main struct of this package. It contains all the information needed to send notifications to a // list of receivers. The receivers are represented by Webhooks and are expected to be valid HTTP endpoints. The // Service also allows. Service struct { client *http.Client webhooks []*Webhook preSendHooks []PreSendHookFn postSendHooks []PostSendHookFn Serializer Serializer } ) const ( defaultUserAgent = "notify/" + notify.Version defaultContentType = "application/json; charset=utf-8" defaultRequestMethod = http.MethodPost // Defining these as constants for testing purposes. defaultSubjectKey = "subject" defaultMessageKey = "message" ) type defaultMarshaller struct{} // Marshal takes a payload and serializes it to a byte slice. The content type is used to determine the serialization // format. If the content type is not supported, an error is returned. The default marshaller supports the following // content types: application/json, text/plain. // NOTE: should we expand the default marshaller to support more content types? func (defaultMarshaller) Marshal(contentType string, payload any) ([]byte, error) { var out []byte var err error switch { case strings.HasPrefix(contentType, "application/json"): out, err = json.Marshal(payload) if err != nil { return nil, fmt.Errorf("marshal payload: %w", err) } case strings.HasPrefix(contentType, "text/plain"): str, ok := payload.(string) if !ok { return nil, fmt.Errorf("payload was expected to be of type string, got %T", payload) } out = []byte(str) default: return nil, errors.New("unsupported content type") } return out, nil } // buildDefaultPayload is the default payload builder. It builds a payload that is a map with the keys "subject" and // "message". func buildDefaultPayload(subject, message string) any { return map[string]string{ defaultSubjectKey: subject, defaultMessageKey: message, } } // New returns a new instance of a Service notification service. Parameter 'tag' is used as a log prefix and may be left // empty, it has a fallback value. func New() *Service { return &Service{ client: http.DefaultClient, webhooks: []*Webhook{}, preSendHooks: []PreSendHookFn{}, postSendHooks: []PostSendHookFn{}, Serializer: defaultMarshaller{}, } } func newWebhook(url string) *Webhook { return &Webhook{ ContentType: defaultContentType, Header: http.Header{}, Method: defaultRequestMethod, URL: url, BuildPayload: buildDefaultPayload, } } // String returns a string representation of the webhook. It implements the fmt.Stringer interface. func (w *Webhook) String() string { if w == nil { return "" } return strings.TrimSpace(fmt.Sprintf("%s %s %s", strings.ToUpper(w.Method), w.URL, w.ContentType)) } // AddReceivers accepts a list of Webhooks and adds them as receivers. The Webhooks are expected to be valid HTTP // endpoints. func (s *Service) AddReceivers(webhooks ...*Webhook) { s.webhooks = append(s.webhooks, webhooks...) } // AddReceiversURLs accepts a list of URLs and adds them as receivers. Internally it converts the URLs to Webhooks by // using the default content-type ("application/json") and request method ("POST"). func (s *Service) AddReceiversURLs(urls ...string) { for _, url := range urls { s.AddReceivers(newWebhook(url)) } } // WithClient sets the http client to be used for sending requests. Calling this method is optional, the default client // will be used if this method is not called. func (s *Service) WithClient(client *http.Client) { if client != nil { s.client = client } } // doPreSendHooks executes all the pre-send hooks. If any of the hooks returns an error, the execution is stopped and // the error is returned. func (s *Service) doPreSendHooks(req *http.Request) error { for _, hook := range s.preSendHooks { if err := hook(req); err != nil { return err } } return nil } // doPostSendHooks executes all the post-send hooks. If any of the hooks returns an error, the execution is stopped and // the error is returned. func (s *Service) doPostSendHooks(req *http.Request, resp *http.Response) error { for _, hook := range s.postSendHooks { if err := hook(req, resp); err != nil { return err } } return nil } // PreSend adds a pre-send hook to the service. The hook will be executed before sending a request to a receiver. func (s *Service) PreSend(hook PreSendHookFn) { s.preSendHooks = append(s.preSendHooks, hook) } // PostSend adds a post-send hook to the service. The hook will be executed after sending a request to a receiver. func (s *Service) PostSend(hook PostSendHookFn) { s.postSendHooks = append(s.postSendHooks, hook) } // newRequest creates a new http request with the given method, content-type, url and payload. Request created by this // function will usually be passed to the Service.do method. func newRequest(ctx context.Context, hook *Webhook, payload io.Reader) (*http.Request, error) { req, err := http.NewRequestWithContext(ctx, hook.Method, hook.URL, payload) if err != nil { return nil, err } req.Header = hook.Header if req.Header.Get("User-Agent") == "" { req.Header.Set("User-Agent", defaultUserAgent) } if req.Header.Get("Content-Type") == "" { req.Header.Set("Content-Type", hook.ContentType) } return req, nil } // do sends the given request and returns an error if the request failed. A failed request gets identified by either // an unsuccessful status code or a non-nil error. The given request is expected to be valid and was usually created // by the newRequest function. func (s *Service) do(req *http.Request) error { // Execute all pre-send hooks in order. if err := s.doPreSendHooks(req); err != nil { return fmt.Errorf("pre-send hooks: %w", err) } // Actually send the HTTP request. resp, err := s.client.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() // Execute all post-send hooks in order. if err = s.doPostSendHooks(req, resp); err != nil { return fmt.Errorf("post-send hooks: %w", err) } // Check if response code is 2xx. Should this be configurable? if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("responded with status code: %d", resp.StatusCode) } return nil } // send is a helper method that sends a message to a single webhook. It wraps the core logic of the Send method, which // is creating a new request for the given webhook and sending it. func (s *Service) send(ctx context.Context, webhook *Webhook, payload []byte) error { // Create a new HTTP request for the given webhook. req, err := newRequest(ctx, webhook, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("create request: %w", err) } defer func() { _ = req.Body.Close() }() return s.do(req) } // Send takes a message and sends it to all webhooks. func (s *Service) Send(ctx context.Context, subject, message string) error { // Send message to all webhooks. for _, webhook := range s.webhooks { select { case <-ctx.Done(): return ctx.Err() default: // Skip webhook if it is nil. if webhook == nil { continue } // Build the payload for the current webhook. payload := webhook.BuildPayload(subject, message) // Marshal the message into a payload. payloadRaw, err := s.Serializer.Marshal(webhook.ContentType, payload) if err != nil { return fmt.Errorf("marshal payload: %w", err) } // Send the payload to the webhook. if err = s.send(ctx, webhook, payloadRaw); err != nil { return fmt.Errorf("send to %s: %w", webhook.URL, err) } } } return nil }