diff --git a/README.md b/README.md index 3869efb..df55d20 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ func main() { // create new api api := monibot.NewApi(apiKey) // send a watchdog heartbeat - err := api.PostWatchdogHeartbeat("5f6d343f517715a471d8768730c3f2f4") + err := api.PostWatchdogHeartbeat("5f6d343a471d87687f51771530c3f2f4") if err != nil { log.Fatal(err) } diff --git a/api.go b/api.go index 97f89f0..f2a3d99 100644 --- a/api.go +++ b/api.go @@ -4,24 +4,83 @@ import ( "context" "encoding/json" "fmt" - "net/http" "strings" + "time" + + "github.com/cvilsmeier/monibot-go/internal/logging" + "github.com/cvilsmeier/monibot-go/internal/sending" + "github.com/cvilsmeier/monibot-go/internal/version" ) +// A Logger prints debug messages. +type Logger = logging.Logger + +// TimeAfterFunc is the function type of time.After. +type TimeAfterFunc = sending.TimeAfterFunc + +// Version is monibot-go sdk version. +const Version = version.Version + +// ApiOptions holds optional parameters for a Api. +type ApiOptions struct { + + // Default is no logging. + Logger Logger + + // Default is "https://monibot.io". + MonibotUrl string + + // Default is 12 trials. + Trials int + + // Default is 5s delay. + Delay time.Duration + + // Default time.After + TimeAfter TimeAfterFunc +} + +// An apiSender provides a Send method and can be overridden in unit tests. +type apiSender interface { + Send(ctx context.Context, method, path string, body []byte) ([]byte, error) +} + // Api provides access to the Monibot REST API. type Api struct { - sender Sender + sender apiSender } -// NewApi creates an Api that sends data to -// https://monibot.io and retries 12 times every 5s if -// an error occurs. +// NewApi creates an Api that sends data to https://monibot.io +// and retries 12 times every 5s if an error occurs, +// and logs nothing. func NewApi(apiKey string) *Api { - return NewApiWithSender(NewRetrySender(NewSender(apiKey))) + return NewApiWithOptions(apiKey, ApiOptions{}) } -// NewApiWithSender creates an Api that uses a custom Sender for sending data. -func NewApiWithSender(sender Sender) *Api { +// NewApiWithOptions creates an Api with custom options. +func NewApiWithOptions(apiKey string, opt ApiOptions) *Api { + logger := opt.Logger + if logger == nil { + logger = logging.NewDiscardLogger() + } + monibotUrl := opt.MonibotUrl + if monibotUrl == "" { + monibotUrl = "http://monibot.io" + } + trials := opt.Trials + if trials == 0 { + trials = 12 + } + delay := opt.Delay + if delay == 0 { + delay = 5 * time.Second + } + timeAfter := opt.TimeAfter + if timeAfter == nil { + timeAfter = time.After + } + transport := sending.NewTransport(logger, Version, monibotUrl, apiKey) + sender := sending.NewSender(transport, logger, trials, delay, timeAfter) return &Api{sender} } @@ -35,7 +94,7 @@ func (a *Api) GetPing() error { // reachable. It returns nil on success or a non-nil error if // something goes wrong. func (a *Api) GetPingWithContext(ctx context.Context) error { - _, err := a.sender.Send(ctx, http.MethodGet, "ping", nil) + _, err := a.sender.Send(ctx, "GET", "ping", nil) return err } @@ -46,7 +105,7 @@ func (a *Api) GetWatchdogs() ([]Watchdog, error) { // GetWatchdogsWithContext fetches the list of watchdogs. func (a *Api) GetWatchdogsWithContext(ctx context.Context) ([]Watchdog, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "watchdogs", nil) + data, err := a.sender.Send(ctx, "GET", "watchdogs", nil) if err != nil { return nil, err } @@ -62,7 +121,7 @@ func (a *Api) GetWatchdog(watchdogId string) (Watchdog, error) { // GetWatchdogWithContext fetches a watchdog by id. func (a *Api) GetWatchdogWithContext(ctx context.Context, watchdogId string) (Watchdog, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "watchdog/"+watchdogId, nil) + data, err := a.sender.Send(ctx, "GET", "watchdog/"+watchdogId, nil) if err != nil { return Watchdog{}, err } @@ -78,7 +137,7 @@ func (a *Api) PostWatchdogHeartbeat(watchdogId string) error { // PostWatchdogHeartbeatWithContext sends a watchdog heartbeat. func (a *Api) PostWatchdogHeartbeatWithContext(ctx context.Context, watchdogId string) error { - _, err := a.sender.Send(ctx, http.MethodPost, "watchdog/"+watchdogId+"/heartbeat", nil) + _, err := a.sender.Send(ctx, "POST", "watchdog/"+watchdogId+"/heartbeat", nil) return err } @@ -89,7 +148,7 @@ func (a *Api) GetMachines() ([]Machine, error) { // GetMachinesWithContext fetches the list of machines. func (a *Api) GetMachinesWithContext(ctx context.Context) ([]Machine, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "machines", nil) + data, err := a.sender.Send(ctx, "GET", "machines", nil) if err != nil { return nil, err } @@ -105,7 +164,7 @@ func (a *Api) GetMachine(machineId string) (Machine, error) { // GetMachineWithContext fetches a machine by id. func (a *Api) GetMachineWithContext(ctx context.Context, machineId string) (Machine, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "machine/"+machineId, nil) + data, err := a.sender.Send(ctx, "GET", "machine/"+machineId, nil) if err != nil { return Machine{}, err } @@ -135,7 +194,7 @@ func (a *Api) PostMachineSampleWithContext(ctx context.Context, machineId string fmt.Sprintf("netSend=%d", sample.NetSend), } body := strings.Join(toks, "&") - _, err := a.sender.Send(ctx, http.MethodPost, "machine/"+machineId+"/sample", []byte(body)) + _, err := a.sender.Send(ctx, "POST", "machine/"+machineId+"/sample", []byte(body)) return err } @@ -146,7 +205,7 @@ func (a *Api) GetMetrics() ([]Metric, error) { // GetMetricsWithContext fetches the list of metrics. func (a *Api) GetMetricsWithContext(ctx context.Context) ([]Metric, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "metrics", nil) + data, err := a.sender.Send(ctx, "GET", "metrics", nil) if err != nil { return nil, err } @@ -162,7 +221,7 @@ func (a *Api) GetMetric(metricId string) (Metric, error) { // GetMetricWithContext fetches a metric by id. func (a *Api) GetMetricWithContext(ctx context.Context, metricId string) (Metric, error) { - data, err := a.sender.Send(ctx, http.MethodGet, "metric/"+metricId, nil) + data, err := a.sender.Send(ctx, "GET", "metric/"+metricId, nil) if err != nil { return Metric{}, err } @@ -182,7 +241,7 @@ func (a *Api) PostMetricInc(metricId string, value int64) error { // The value is a non-negative int64 number. func (a *Api) PostMetricIncWithContext(ctx context.Context, metricId string, value int64) error { body := fmt.Sprintf("value=%d", value) - _, err := a.sender.Send(ctx, http.MethodPost, "metric/"+metricId+"/inc", []byte(body)) + _, err := a.sender.Send(ctx, "POST", "metric/"+metricId+"/inc", []byte(body)) return err } @@ -197,6 +256,6 @@ func (a *Api) PostMetricSet(metricId string, value int64) error { // The value is a non-negative int64 number. func (a *Api) PostMetricSetWithContext(ctx context.Context, metricId string, value int64) error { body := fmt.Sprintf("value=%d", value) - _, err := a.sender.Send(ctx, http.MethodPost, "metric/"+metricId+"/set", []byte(body)) + _, err := a.sender.Send(ctx, "POST", "metric/"+metricId+"/set", []byte(body)) return err } diff --git a/api_test.go b/api_test.go index 8f30a11..2088a1a 100644 --- a/api_test.go +++ b/api_test.go @@ -1,7 +1,9 @@ package monibot import ( + "context" "fmt" + "strings" "testing" "time" @@ -24,20 +26,20 @@ func TestApi(t *testing.T) { // this test uses a fake HTTP sender sender := &fakeSender{} // create Api - api := NewApiWithSender(sender) + api := &Api{sender} // GET ping { - sender.requests = nil + sender.calls = nil sender.responses = append(sender.responses, fakeResponse{}) err := api.GetPing() ass.Nil(err) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET ping", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET ping", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET watchdogs { - sender.requests = nil + sender.calls = nil resp := `[ {"id":"0001", "name":"Cronjob 1", "intervalMillis": 72000000}, {"id":"0002", "name":"Cronjob 2", "intervalMillis": 36000000} @@ -48,35 +50,35 @@ func TestApi(t *testing.T) { ass.Eq(2, len(watchdogs)) ass.Eq("Id=0001, Name=Cronjob 1, IntervalMillis=72000000", str(watchdogs[0])) ass.Eq("Id=0002, Name=Cronjob 2, IntervalMillis=36000000", str(watchdogs[1])) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET watchdogs", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET watchdogs", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET watchdog/00000001 { - sender.requests = nil + sender.calls = nil resp := `{"id":"0001", "name":"Cronjob 1", "intervalMillis": 72000000}` sender.responses = append(sender.responses, fakeResponse{data: []byte(resp)}) watchdog, err := api.GetWatchdog("00000001") ass.Nil(err) ass.Eq("Id=0001, Name=Cronjob 1, IntervalMillis=72000000", str(watchdog)) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET watchdog/00000001", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET watchdog/00000001", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // POST watchdog/00000001/heartbeat { - sender.requests = nil + sender.calls = nil sender.responses = append(sender.responses, fakeResponse{}) err := api.PostWatchdogHeartbeat("00000001") ass.Nil(err) - ass.Eq(1, len(sender.requests)) - ass.Eq("POST watchdog/00000001/heartbeat", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("POST watchdog/00000001/heartbeat", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET machines { - sender.requests = nil + sender.calls = nil resp := `[ {"id":"01", "name":"Server 1"}, {"id":"02", "name":"Server 2"} @@ -87,25 +89,25 @@ func TestApi(t *testing.T) { ass.Eq(2, len(machines)) ass.Eq("Id=01, Name=Server 1", str(machines[0])) ass.Eq("Id=02, Name=Server 2", str(machines[1])) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET machines", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET machines", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET machine/01 { - sender.requests = nil + sender.calls = nil resp := `{"id":"01", "name":"Server 1"}` sender.responses = append(sender.responses, fakeResponse{data: []byte(resp)}) machine, err := api.GetMachine("01") ass.Nil(err) ass.Eq("Id=01, Name=Server 1", str(machine)) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET machine/01", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET machine/01", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // POST machine/00000001/sample { - sender.requests = nil + sender.calls = nil sender.responses = append(sender.responses, fakeResponse{}) tstamp := time.Date(2023, 10, 27, 10, 0, 0, 0, time.UTC) sample := MachineSample{ @@ -123,13 +125,13 @@ func TestApi(t *testing.T) { } err := api.PostMachineSample("00000001", sample) ass.Nil(err) - ass.Eq(1, len(sender.requests)) - ass.Eq("POST machine/00000001/sample tstamp=1698400800000&load1=1.010&load5=0.780&load15=0.120&cpu=12&mem=34&disk=12&diskReads=678&diskWrites=567&netRecv=13&netSend=14", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("POST machine/00000001/sample tstamp=1698400800000&load1=1.010&load5=0.780&load15=0.120&cpu=12&mem=34&disk=12&diskReads=678&diskWrites=567&netRecv=13&netSend=14", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET metrics { - sender.requests = nil + sender.calls = nil resp := `[ {"id":"01", "name":"Metric 1", "type": 0}, {"id":"02", "name":"Metric 2", "type": 1} @@ -140,30 +142,51 @@ func TestApi(t *testing.T) { ass.Eq(2, len(metrics)) ass.Eq("Id=01, Name=Metric 1, Type=0", str(metrics[0])) ass.Eq("Id=02, Name=Metric 2, Type=1", str(metrics[1])) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET metrics", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET metrics", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // GET metric/01 { - sender.requests = nil + sender.calls = nil resp := `{"id":"01", "name":"Metric 1", "type": 0}` sender.responses = append(sender.responses, fakeResponse{data: []byte(resp)}) metric, err := api.GetMetric("01") ass.Nil(err) ass.Eq("Id=01, Name=Metric 1, Type=0", str(metric)) - ass.Eq(1, len(sender.requests)) - ass.Eq("GET metric/01", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("GET metric/01", sender.calls[0]) ass.Eq(0, len(sender.responses)) } // POST metric/00000001/inc { - sender.requests = nil + sender.calls = nil sender.responses = append(sender.responses, fakeResponse{nil, fmt.Errorf("connect timeout")}) err := api.PostMetricInc("00000001", 42) ass.Eq("connect timeout", err.Error()) - ass.Eq(1, len(sender.requests)) - ass.Eq("POST metric/00000001/inc value=42", sender.requests[0]) + ass.Eq(1, len(sender.calls)) + ass.Eq("POST metric/00000001/inc value=42", sender.calls[0]) ass.Eq(0, len(sender.responses)) } } + +type fakeSender struct { + calls []string + responses []fakeResponse +} + +func (f *fakeSender) Send(ctx context.Context, method, path string, body []byte) ([]byte, error) { + call := strings.TrimSpace(method + " " + path + " " + string(body)) + f.calls = append(f.calls, call) + if len(f.responses) == 0 { + return nil, fmt.Errorf("no response for %s %s", method, path) + } + re := f.responses[0] + f.responses = f.responses[1:] + return re.data, re.err +} + +type fakeResponse struct { + data []byte + err error +} diff --git a/doc.go b/doc.go index 0419299..66a6b16 100644 --- a/doc.go +++ b/doc.go @@ -10,7 +10,7 @@ see https://monibot.io for details. // create new api api := monibot.NewApi(apiKey) // send a watchdog heartbeat - err := api.PostWatchdogHeartbeat("5f6d343f517715a471d8768730c3f2f4") + err := api.PostWatchdogHeartbeat("5f6d343a471d87687f51771530c3f2f4") if err != nil { log.Fatal(err) } diff --git a/doc_test.go b/doc_test.go deleted file mode 100644 index 959af56..0000000 --- a/doc_test.go +++ /dev/null @@ -1,62 +0,0 @@ -package monibot - -import ( - "os" - "strings" - "testing" - - "github.com/cvilsmeier/monibot-go/internal/assert" -) - -func TestExampleSnippets(t *testing.T) { - ass := assert.New(t) - // parse example/main.go - data, err := os.ReadFile("example/main.go") - ass.Nil(err) - want, found := cutout(normalizeText(string(data)), "func main() {", "}\n}") - ass.True(found) - // parse README.md - filename := "README.md" - data, err = os.ReadFile(filename) - ass.Nil(err) - have, found := cutout(normalizeText(string(data)), "func main() {", "}\n}") - ass.True(found) - ass.Eq(filename+":"+want, filename+":"+have) - // parse doc.go - filename = "doc.go" - data, err = os.ReadFile(filename) - ass.Nil(err) - have, found = cutout(normalizeText(string(data)), "func main() {", "}\n}") - ass.True(found) - ass.Eq(filename+":"+want, filename+":"+have) -} - -func normalizeText(s string) string { - s = replace(s, "\r", "") - s = replace(s, "\t", " ") - s = replace(s, " ", " ") - s = replace(s, "\n ", "\n") - return strings.TrimSpace(s) -} - -func replace(str, old, new string) string { - i := 0 - for strings.Contains(str, old) && i < 1000 { - str = strings.ReplaceAll(str, old, new) - i++ - } - return str -} - -func cutout(s, pre, post string) (string, bool) { - i := strings.Index(s, pre) - if i < 0 { - return "", false - } - s = s[i+len(pre):] - i = strings.Index(s, post) - if i < 0 { - return "", false - } - return s[:i], true -} diff --git a/example/main.go b/example/main.go index 44ceb66..e90ae3c 100644 --- a/example/main.go +++ b/example/main.go @@ -13,7 +13,7 @@ func main() { // create new api api := monibot.NewApi(apiKey) // send a watchdog heartbeat - err := api.PostWatchdogHeartbeat("5f6d343f517715a471d8768730c3f2f4") + err := api.PostWatchdogHeartbeat("5f6d343a471d87687f51771530c3f2f4") if err != nil { log.Fatal(err) } diff --git a/logger.go b/internal/logging/logger.go similarity index 97% rename from logger.go rename to internal/logging/logger.go index 4b28f7b..5817476 100644 --- a/logger.go +++ b/internal/logging/logger.go @@ -1,4 +1,4 @@ -package monibot +package logging import ( "log" diff --git a/internal/sending/sender.go b/internal/sending/sender.go new file mode 100644 index 0000000..d7023cd --- /dev/null +++ b/internal/sending/sender.go @@ -0,0 +1,82 @@ +package sending + +import ( + "context" + "fmt" + "time" + + "github.com/cvilsmeier/monibot-go/internal/logging" +) + +// TimeAfterFunc is the function type of time.After. +type TimeAfterFunc func(time.Duration) <-chan time.Time + +type senderTransport interface { + Send(ctx context.Context, method, path string, body []byte) (int, []byte, error) +} + +type Sender struct { + transport senderTransport + logger logging.Logger + trials int + delay time.Duration + timeAfter TimeAfterFunc +} + +func NewSender(transport senderTransport, logger logging.Logger, trials int, delay time.Duration, timeAfter TimeAfterFunc) *Sender { + if trials < 1 { + trials = 1 + } + if delay < 0 { + delay = 0 + } + return &Sender{transport, logger, trials, delay, timeAfter} +} + +func (s *Sender) Send(ctx context.Context, method, path string, body []byte) ([]byte, error) { + var trial int + for { + trial++ + s.logger.Debug("trial #%d/%d for %s %s", trial, s.trials, method, path) + status, data, err := s.transport.Send(ctx, method, path, body) + done := isDone(status, err) + if done || trial >= s.trials { + if err == nil && !done { + err = fmt.Errorf("status %d", status) + } + return data, err + } + select { + case <-s.timeAfter(s.delay): + // retry now + case <-ctx.Done(): + return nil, fmt.Errorf("cancelled") + } + } +} + +func isDone(status int, err error) bool { + if err != nil { + // technical error + // -> retry + return false + } + if status == 200 { + // success + // -> done + return true + } + if status == 429 { + // rate limit + // -> retry + return false + } + if 400 <= status && status <= 499 { + // error in request data + // -> done, because next trial will probably bring the same result + return true + } + // other status code (5xx) (server maintenance, nginx bad gateway, ...) + // -> retry + return false +} diff --git a/internal/sending/sender_test.go b/internal/sending/sender_test.go new file mode 100644 index 0000000..050c6c9 --- /dev/null +++ b/internal/sending/sender_test.go @@ -0,0 +1,86 @@ +package sending + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/cvilsmeier/monibot-go/internal/assert" +) + +func TestRetrySender(t *testing.T) { + ass := assert.New(t) + transport := &fakeTransport{} + timeChan := make(chan time.Time) + logger := &fakeLogger{t, false} + timeAfter := func(d time.Duration) <-chan time.Time { + return timeChan + } + trials := 3 + delay := 2 * time.Second + sender := NewSender(transport, logger, trials, delay, timeAfter) + // must retry if network error + transport.responses = []fakeResponse{ + {0, nil, fmt.Errorf("connection refused")}, + {0, nil, fmt.Errorf("connection refused")}, + {200, []byte("{\"ok\":true}"), nil}, + } + go func() { + timeChan <- time.Now() + timeChan <- time.Now() + }() + data, err := sender.Send(context.Background(), "GET", "/ping", nil) + ass.Eq(3, len(transport.calls)) + ass.Eq("GET /ping", transport.calls[0]) + ass.Eq("GET /ping", transport.calls[1]) + ass.Eq("GET /ping", transport.calls[2]) + ass.Nil(err) + ass.Eq("{\"ok\":true}", string(data)) + transport.calls = nil + // must retry max trials + transport.responses = []fakeResponse{ + {0, nil, fmt.Errorf("connection error 1")}, + {0, nil, fmt.Errorf("connection error 2")}, + {0, nil, fmt.Errorf("connection error 3")}, + } + go func() { + timeChan <- time.Now() + timeChan <- time.Now() + }() + _, err = sender.Send(context.Background(), "GET", "/ping", nil) + ass.Eq(3, len(transport.calls)) + ass.Eq("GET /ping", transport.calls[0]) + ass.Eq("GET /ping", transport.calls[1]) + ass.Eq("GET /ping", transport.calls[2]) + ass.Eq("connection error 3", err.Error()) + transport.calls = nil + // must retry if status 502 (bad gateway) + transport.responses = []fakeResponse{ + {502, nil, nil}, + {502, nil, nil}, + {200, []byte("{\"ok\":true}"), nil}, + } + go func() { + timeChan <- time.Now() + timeChan <- time.Now() + }() + data, err = sender.Send(context.Background(), "GET", "/ping", nil) + ass.Eq(3, len(transport.calls)) + ass.Eq("GET /ping", transport.calls[0]) + ass.Eq("GET /ping", transport.calls[1]) + ass.Eq("GET /ping", transport.calls[2]) + ass.Nil(err) + ass.Eq("{\"ok\":true}", string(data)) + transport.calls = nil + // must not retry if 404 (not found) + transport.responses = []fakeResponse{ + {404, nil, nil}, + } + data, err = sender.Send(context.Background(), "GET", "/wrongUrl", nil) + ass.Eq(1, len(transport.calls)) + ass.Eq("GET /wrongUrl", transport.calls[0]) + ass.Nil(err) + ass.Eq("", string(data)) + transport.calls = nil +} diff --git a/internal/sending/testing_test.go b/internal/sending/testing_test.go new file mode 100644 index 0000000..643097f --- /dev/null +++ b/internal/sending/testing_test.go @@ -0,0 +1,45 @@ +package sending + +import ( + "context" + "fmt" + "testing" +) + +// fakeTransport is a Transport for unit tests +type fakeTransport struct { + calls []string + responses []fakeResponse +} + +func (f *fakeTransport) Send(ctx context.Context, method, path string, body []byte) (int, []byte, error) { + call := fmt.Sprintf("%s %s", method, path) + if len(body) > 0 { + call += fmt.Sprintf(" %s", string(body)) + } + f.calls = append(f.calls, call) + if len(f.responses) == 0 { + return 0, nil, fmt.Errorf("fakeSender is out of responses for request %s %s", method, path) + } + re := f.responses[0] + f.responses = f.responses[1:] + return re.status, re.data, re.err +} + +type fakeResponse struct { + status int + data []byte + err error +} + +// fakeLogger is a Logger for unit tests +type fakeLogger struct { + t testing.TB + enabled bool +} + +func (f *fakeLogger) Debug(format string, args ...any) { + if f.enabled { + f.t.Logf(format, args...) + } +} diff --git a/internal/sending/transport.go b/internal/sending/transport.go new file mode 100644 index 0000000..6f93ad8 --- /dev/null +++ b/internal/sending/transport.go @@ -0,0 +1,58 @@ +package sending + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + + "github.com/cvilsmeier/monibot-go/internal/logging" +) + +type Transport struct { + logger logging.Logger + version string + apiUrl string + apiKey string +} + +func NewTransport(logger logging.Logger, version, monibotUrl, apiKey string) *Transport { + return &Transport{logger, version, monibotUrl + "/api/", apiKey} +} + +func (s *Transport) Send(ctx context.Context, method, path string, body []byte) (int, []byte, error) { + urlpath := s.apiUrl + path + s.logger.Debug("%s %s", method, urlpath) + if len(body) > 0 { + s.logger.Debug("body=%s", string(body)) + } + bodyReader := bytes.NewReader(body) + req, err := http.NewRequestWithContext(ctx, method, urlpath, bodyReader) + if err != nil { + s.logger.Debug("cannot create request: %s", err) + return 0, nil, err + } + if len(body) > 0 { + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + } + req.Header.Set("User-Agent", "monibot/"+s.version) + req.Header.Set("Authorization", "Bearer "+s.apiKey) + req.Header.Set("Accept", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + s.logger.Debug("%s %s: %s", req.Method, urlpath, err) + return 0, nil, err + } + defer resp.Body.Close() + data, err := io.ReadAll(resp.Body) + if err != nil { + return 0, nil, fmt.Errorf("cannot read response data: %w", err) + } + if len(data) > 256 { + s.logger.Debug("%d (%d bytes) %s", resp.StatusCode, len(data), string(data)[:256]+"...") + } else { + s.logger.Debug("%d (%d bytes) %s", resp.StatusCode, len(data), string(data)) + } + return resp.StatusCode, data, nil +} diff --git a/internal/sending/transport_test.go b/internal/sending/transport_test.go new file mode 100644 index 0000000..0431231 --- /dev/null +++ b/internal/sending/transport_test.go @@ -0,0 +1,42 @@ +package sending + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/cvilsmeier/monibot-go/internal/assert" +) + +func TestSender(t *testing.T) { + ass := assert.New(t) + // setup fake api http server + mux := http.NewServeMux() + mux.HandleFunc("/api/ok", func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "ok") + }) + mux.HandleFunc("/api/500", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + }) + server := httptest.NewServer(mux) + defer server.Close() + // init + logger := &fakeSenderLogger{} + sender := NewTransport(logger, "v1.2.3", server.URL, "api-key-123") + // send ok + status, data, err := sender.Send(context.Background(), "GET", "/ok", nil) + ass.Nil(err) + ass.Eq(200, status) + ass.Eq("ok", string(data)) + // send 500 + status, data, err = sender.Send(context.Background(), "POST", "/500", nil) + ass.Nil(err) + ass.Eq(500, status) + ass.Eq("", string(data)) +} + +type fakeSenderLogger struct{} + +func (f *fakeSenderLogger) Debug(format string, args ...any) {} diff --git a/version.go b/internal/version/version.go similarity index 80% rename from version.go rename to internal/version/version.go index 879e7bc..b20cbc5 100644 --- a/version.go +++ b/internal/version/version.go @@ -1,4 +1,4 @@ -package monibot +package version // Version is monibot-go sdk version. const Version = "v0.0.7" diff --git a/models.go b/models.go index 83ce737..3047552 100644 --- a/models.go +++ b/models.go @@ -37,16 +37,16 @@ type MachineSample struct { // Disk usage percent 0..100. DiskPercent int - // Disk number of sectors read since last sample. + // Number of disk sectors read since last sample. DiskReads int64 - // Disk number of sectors written since last sample. + // Number of disk sectors written since last sample. DiskWrites int64 - // Network number of bytes received since last sample. + // Number of network bytes received since last sample. NetRecv int64 - // Network number of bytes sent since last sample. + // Number of network bytes sent since last sample. NetSend int64 } diff --git a/retry.go b/retry.go deleted file mode 100644 index 04c3648..0000000 --- a/retry.go +++ /dev/null @@ -1,86 +0,0 @@ -package monibot - -import ( - "context" - "fmt" - "time" -) - -// RetrySender wraps a Sender and re-sends API requests in case of error. -type RetrySender struct { - logger Logger - sender Sender - timeAfter func(time.Duration) <-chan time.Time - trials int - delay time.Duration -} - -var _ Sender = (*RetrySender)(nil) - -// RetrySenderOptions hold RetrySender opptions. -type RetrySenderOptions struct { - - // Default logs nothing. - Logger Logger - - // Default is time.After. - TimeAfter func(time.Duration) <-chan time.Time - - // Default is 12. - Trials int - - // Default is 5s. - Delay time.Duration -} - -// NewRetrySender creates a new RetrySender that does max. 12 trials with a delay of 5 seconds in between. -func NewRetrySender(sender Sender) *RetrySender { - return NewRetrySenderWithOptions(sender, RetrySenderOptions{}) -} - -// NewRetrySenderWithOptions creates a new RetrySender with custom options. -func NewRetrySenderWithOptions(sender Sender, opt RetrySenderOptions) *RetrySender { - if sender == nil { - panic("sender == nil") - } - if opt.Logger == nil { - opt.Logger = NewDiscardLogger() - } - if opt.TimeAfter == nil { - opt.TimeAfter = time.After - } - if opt.Trials < 1 { - opt.Trials = 12 - } - if opt.Delay <= 0 { - opt.Delay = 5 * time.Second - } - return &RetrySender{opt.Logger, sender, opt.TimeAfter, opt.Trials, opt.Delay} -} - -func (s *RetrySender) Send(ctx context.Context, method, path string, body []byte) ([]byte, error) { - // first trial, always - s.logger.Debug("trial #1 for %s %s", method, path) - data, err := s.sender.Send(ctx, method, path, body) - if err == nil { - return data, err - } - // we have to retry again and again - // TODO: if we know that the error will be persistent on retries, - // for instance because the user wants to increment a non-counter - // metric, we might as well bail out and return early. - for i := 1; i < s.trials; i++ { - select { - case <-s.timeAfter(s.delay): - // retry - s.logger.Debug("trial #%d for %s %s", i+1, method, path) - data, err = s.sender.Send(ctx, method, path, body) - if err == nil { - return data, nil - } - case <-ctx.Done(): - return nil, fmt.Errorf("cancelled") - } - } - return data, err -} diff --git a/retry_test.go b/retry_test.go deleted file mode 100644 index ea97639..0000000 --- a/retry_test.go +++ /dev/null @@ -1,42 +0,0 @@ -package monibot - -import ( - "context" - "fmt" - "testing" - "time" - - "github.com/cvilsmeier/monibot-go/internal/assert" -) - -func TestRetrySender(t *testing.T) { - ass := assert.New(t) - sender := &fakeSender{} - timeChan := make(chan time.Time) - logger := &fakeLogger{t, false} - retry := NewRetrySenderWithOptions(sender, RetrySenderOptions{ - Logger: logger, - TimeAfter: func(d time.Duration) <-chan time.Time { - return timeChan - }, - Trials: 3, - Delay: 2 * time.Second, - }) - // ping - sender.responses = []fakeResponse{ - {nil, fmt.Errorf("connection refused")}, - {nil, fmt.Errorf("connection refused")}, - {[]byte("ok"), nil}, - } - go func() { - timeChan <- time.Now() - timeChan <- time.Now() - }() - data, err := retry.Send(context.Background(), "GET", "/ping", nil) - ass.Eq(3, len(sender.requests)) - ass.Eq("GET /ping", sender.requests[0]) - ass.Eq("GET /ping", sender.requests[1]) - ass.Eq("GET /ping", sender.requests[2]) - ass.Nil(err) - ass.Eq("ok", string(data)) -} diff --git a/sender.go b/sender.go deleted file mode 100644 index 9ae8963..0000000 --- a/sender.go +++ /dev/null @@ -1,98 +0,0 @@ -package monibot - -import ( - "bytes" - "context" - "fmt" - "io" - "net/http" -) - -// A Sender sends HTTP requests and receives HTTP responses. -// It is used by Api to send HTTP requests. -type Sender interface { - - // Send sends a HTTP request. - // It returns the raw response data or an error. - Send(ctx context.Context, method, path string, body []byte) ([]byte, error) -} - -// SenderOptions holds optional parameters for a Sender. -type SenderOptions struct { - - // Default logs nothing. - Logger Logger - - // Default is "https://monibot.io". - MonibotUrl string -} - -// httpSender is a Sender that uses HTTP for sending API requests. -type httpSender struct { - logger Logger - apiUrl string - apiKey string -} - -var _ Sender = (*httpSender)(nil) - -// NewSender creates a new Sender that sends api requests to https://monibot.io. -func NewSender(apiKey string) Sender { - return NewSenderWithOptions(apiKey, SenderOptions{}) -} - -// NewSenderWithOptions creates a new Sender with custom options. -func NewSenderWithOptions(apiKey string, opt SenderOptions) Sender { - if opt.Logger == nil { - opt.Logger = NewDiscardLogger() - } - if opt.MonibotUrl == "" { - opt.MonibotUrl = "https://monibot.io" - } - return &httpSender{opt.Logger, opt.MonibotUrl + "/api/", apiKey} -} - -// Send sends a HTTP request. -// It returns the raw response data or an error. -func (s *httpSender) Send(ctx context.Context, method, path string, body []byte) ([]byte, error) { - urlpath := s.apiUrl + path - s.logger.Debug("%s %s", method, urlpath) - if len(body) > 0 { - s.logger.Debug("body=%s", string(body)) - } - bodyReader := bytes.NewReader(body) - req, err := http.NewRequestWithContext(ctx, method, urlpath, bodyReader) - if err != nil { - s.logger.Debug("cannot create request: %s", err) - return nil, err - } - if len(body) > 0 { - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - } - req.Header.Set("User-Agent", "monibot/"+Version) - req.Header.Set("Authorization", "Bearer "+s.apiKey) - req.Header.Set("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - s.logger.Debug("%s %s: %s", req.Method, urlpath, err) - return nil, err - } - defer resp.Body.Close() - data, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("cannot read response data: %w", err) - } - if len(data) > 256 { - s.logger.Debug("%d (%d bytes) %s", resp.StatusCode, len(data), string(data)[:256]+"...") - } else { - s.logger.Debug("%d (%d bytes) %s", resp.StatusCode, len(data), string(data)) - } - if resp.StatusCode < 200 || 299 < resp.StatusCode { - text := string(data) - if text == "" { - return nil, fmt.Errorf("response status %d", resp.StatusCode) - } - return nil, fmt.Errorf("response status %d: %s", resp.StatusCode, text) - } - return data, nil -} diff --git a/sender_test.go b/sender_test.go deleted file mode 100644 index 9402a80..0000000 --- a/sender_test.go +++ /dev/null @@ -1,40 +0,0 @@ -package monibot - -import ( - "context" - "fmt" - "net/http" - "net/http/httptest" - "sync/atomic" - "testing" - - "github.com/cvilsmeier/monibot-go/internal/assert" -) - -func TestSend(t *testing.T) { - ass := assert.New(t) - // setup fake api http server - var pingOk atomic.Bool - mux := http.NewServeMux() - mux.HandleFunc("/api/ping", func(w http.ResponseWriter, r *http.Request) { - if !pingOk.Load() { - w.WriteHeader(500) - return - } - fmt.Fprintf(w, "ok") - }) - server := httptest.NewServer(mux) - defer server.Close() - // init sender - sender := NewSenderWithOptions("123456", SenderOptions{MonibotUrl: server.URL}) - // send ping - good - pingOk.Store(true) - data, err := sender.Send(context.Background(), "GET", "/ping", nil) - ass.Nil(err) - ass.Eq("ok", string(data)) - // send ping - error - pingOk.Store(false) - data, err = sender.Send(context.Background(), "GET", "/ping", nil) - ass.Eq("response status 500", err.Error()) - ass.Eq("", string(data)) -} diff --git a/testing_test.go b/testing_test.go deleted file mode 100644 index bc6369f..0000000 --- a/testing_test.go +++ /dev/null @@ -1,48 +0,0 @@ -package monibot - -import ( - "context" - "fmt" - "testing" -) - -// fakeSender is a Sender for unit tests -type fakeSender struct { - requests []string - responses []fakeResponse -} - -var _ Sender = (*fakeSender)(nil) - -func (f *fakeSender) Send(ctx context.Context, method, path string, data []byte) ([]byte, error) { - req := fmt.Sprintf("%s %s", method, path) - if len(data) > 0 { - req += fmt.Sprintf(" %s", string(data)) - } - f.requests = append(f.requests, req) - if len(f.responses) == 0 { - return nil, fmt.Errorf("fakeSender is out of responses, request was %s %s", method, path) - } - tmp := f.responses[0] - f.responses = f.responses[1:] - return tmp.data, tmp.err -} - -type fakeResponse struct { - data []byte - err error -} - -// fakeLogger is a Logger for unit tests -type fakeLogger struct { - t testing.TB - enabled bool -} - -var _ Logger = (*fakeLogger)(nil) - -func (f *fakeLogger) Debug(format string, args ...any) { - if f.enabled { - f.t.Logf(format, args...) - } -} diff --git a/version_test.go b/version_test.go deleted file mode 100644 index 948ba65..0000000 --- a/version_test.go +++ /dev/null @@ -1,23 +0,0 @@ -package monibot - -import ( - "os" - "testing" - - "github.com/cvilsmeier/monibot-go/internal/assert" -) - -func TestVersion(t *testing.T) { - // version.go and README.md must have same version - ass := assert.New(t) - // Version must start with "v" - ass.True(len(Version) >= 6) - ass.Eq("v", Version[0:1]) - // README.md version must be equal - filename := "README.md" - data, err := os.ReadFile(filename) - ass.Nil(err) - readmeVersion, found := cutout(string(data), "### v", "\n") - ass.True(found) - ass.Eq(filename+": "+Version, filename+": v"+readmeVersion) -}