nikoksr/notify

View on GitHub
service/http/http.go

Summary

Maintainability
A
35 mins
Test Coverage
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
}