From 4612ee993e6fb420550ecbbee67648a484bf8aec Mon Sep 17 00:00:00 2001 From: mstar Date: Wed, 9 Apr 2025 13:57:01 +0200 Subject: [PATCH 01/23] BREAKING CHANGE: Rename http module to webutils --- http/chain.go | 2 +- http/context.go | 2 +- http/httpErr.go | 2 +- http/json.go | 17 +++++++++++++++++ http/zerolog.go | 2 +- 5 files changed, 21 insertions(+), 4 deletions(-) create mode 100644 http/json.go diff --git a/http/chain.go b/http/chain.go index aa1983a..30dfb71 100644 --- a/http/chain.go +++ b/http/chain.go @@ -1,4 +1,4 @@ -package http +package webutils import ( "net/http" diff --git a/http/context.go b/http/context.go index ad6619e..041aa63 100644 --- a/http/context.go +++ b/http/context.go @@ -1,4 +1,4 @@ -package http +package webutils import ( "context" diff --git a/http/httpErr.go b/http/httpErr.go index 61337a1..fc87acb 100644 --- a/http/httpErr.go +++ b/http/httpErr.go @@ -1,4 +1,4 @@ -package http +package webutils import ( "encoding/json" diff --git a/http/json.go b/http/json.go new file mode 100644 index 0000000..ac6e4ae --- /dev/null +++ b/http/json.go @@ -0,0 +1,17 @@ +package webutils + +import ( + "encoding/json" + "fmt" + "net/http" +) + +func SendJson(w http.ResponseWriter, data any) error { + encoded, err := json.Marshal(data) + if err != nil { + return err + } + w.Header().Add("Content-Type", "application/json") + fmt.Fprint(w, string(encoded)) + return nil +} diff --git a/http/zerolog.go b/http/zerolog.go index 70e8caa..10b2cd2 100644 --- a/http/zerolog.go +++ b/http/zerolog.go @@ -1,4 +1,4 @@ -package http +package webutils import ( "net/http" From e8aa16622b71139d7a5f8b3d27c18978c5a4f0c5 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 10 Apr 2025 14:36:51 +0200 Subject: [PATCH 02/23] Add generic math abs --- math/math.go | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 math/math.go diff --git a/math/math.go b/math/math.go new file mode 100644 index 0000000..8f04ebc --- /dev/null +++ b/math/math.go @@ -0,0 +1,13 @@ +package mathutils + +type SignedNumber interface { + ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~float32 | ~float64 +} + +func Abs[T SignedNumber](num T) T { + if num > 0 { + return num + } else { + return num * -1 + } +} From 15887d9d2e480aa7d511a560904929a05a8682ec Mon Sep 17 00:00:00 2001 From: mstar Date: Fri, 11 Apr 2025 10:44:50 +0200 Subject: [PATCH 03/23] Add builder for Logging middleware Builder offers option to add extra fields to log messages --- http/zerolog.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/http/zerolog.go b/http/zerolog.go index 10b2cd2..61cec54 100644 --- a/http/zerolog.go +++ b/http/zerolog.go @@ -9,6 +9,33 @@ import ( "github.com/rs/zerolog/log" ) +func BuildLoggingMiddleware(extras map[string]string) HandlerBuilder { + return func(h http.Handler) http.Handler { + return ChainMiddlewares(h, + hlog.NewHandler(log.Logger), + hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { + if strings.HasPrefix(r.URL.Path, "/assets") { + return + } + logger := hlog.FromRequest(r).Info(). + Str("method", r.Method). + Stringer("url", r.URL). + Int("status", status). + Int("size", size). + Dur("duration", duration) + for k, v := range extras { + logger = logger.Str(k, v) + } + logger.Send() + }), + hlog.RemoteAddrHandler("ip"), + hlog.UserAgentHandler("user_agent"), + hlog.RefererHandler("referer"), + hlog.RequestIDHandler("req_id", "Request-Id"), + ) + } +} + func LoggingMiddleware(handler http.Handler) http.Handler { return ChainMiddlewares(handler, hlog.NewHandler(log.Logger), From 9870d87d41558163ce0177307798a1a56a2dce73 Mon Sep 17 00:00:00 2001 From: mstar Date: Fri, 11 Apr 2025 12:38:41 +0200 Subject: [PATCH 04/23] Add sliceutils.ContainsFunc --- sliceutils/sliceUtils.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/sliceutils/sliceUtils.go b/sliceutils/sliceUtils.go index 00d6703..19a9a6f 100644 --- a/sliceutils/sliceUtils.go +++ b/sliceutils/sliceUtils.go @@ -81,6 +81,15 @@ func Contains[T comparable](a []T, b T) bool { return false } +func ContainsFunc[T any](a []T, f func(t T) bool) bool { + for _, v := range a { + if f(v) { + return true + } + } + return false +} + func Compact[T any](a []T, compactor func(acc T, next T) T) T { var acc T for _, v := range a { From 09de0a19e1a24133bfa14d2c472370fe4bfd6d81 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:52:43 +0200 Subject: [PATCH 05/23] Change queues and stacks - Make chain element private - Remove JSON Marshaller from stack and queue - Remove access to top chain element --- containers/generics.go | 12 ++++++------ containers/queues.go | 42 ++++++++++-------------------------------- containers/stacks.go | 16 ++-------------- 3 files changed, 18 insertions(+), 52 deletions(-) diff --git a/containers/generics.go b/containers/generics.go index 17c1c4d..9465afd 100644 --- a/containers/generics.go +++ b/containers/generics.go @@ -1,15 +1,15 @@ package containers -type ChainElem[T any] struct { +type chainElem[T any] struct { Elem *T - Next *ChainElem[T] + Next *chainElem[T] } // reachable checks if you can reach elem l when starting from elem f. // It detects loops and returns false if it runs into one. -func reachable[T any](f, l *ChainElem[T]) bool { +func reachable[T any](f, l *chainElem[T]) bool { // Map to keep track of nodes already visited - checks := make(map[*ChainElem[T]]bool) + checks := make(map[*chainElem[T]]bool) for w := f; w != l; w = w.Next { if w == nil { return false @@ -26,8 +26,8 @@ func reachable[T any](f, l *ChainElem[T]) bool { } // emptyElem creates a new ChainElem[T] with empty values. -func emptyElem[T any]() *ChainElem[T] { - return &ChainElem[T]{ +func emptyElem[T any]() *chainElem[T] { + return &chainElem[T]{ Elem: nil, Next: nil, } diff --git a/containers/queues.go b/containers/queues.go index b528692..f590293 100644 --- a/containers/queues.go +++ b/containers/queues.go @@ -1,13 +1,15 @@ package containers import ( - "encoding/json" "errors" ) +var ErrInvalidQueue = errors.New("invalid queue") +var ErrEmptyQueue = errors.New("empty queue") + type Queue[T any] struct { - head *ChainElem[T] - tail *ChainElem[T] + head *chainElem[T] + tail *chainElem[T] } // isValid checks if the queue is still valid. @@ -25,7 +27,7 @@ func (q *Queue[T]) IsEmpty() bool { // Push adds a new element to the end of the queue. func (q *Queue[T]) Push(elem *T) error { if !q.isValid() { - return errors.New("invalid queue") + return ErrInvalidQueue } e := emptyElem[T]() @@ -40,10 +42,10 @@ func (q *Queue[T]) Push(elem *T) error { // It errors out if there is no element or the queue is invalid. func (q *Queue[T]) Pop() (*T, error) { if !q.isValid() { - return nil, errors.New("invalid queue") + return nil, ErrInvalidQueue } if q.IsEmpty() { - return nil, errors.New("empty queue") + return nil, ErrEmptyQueue } Elem := q.head.Elem q.head = q.head.Next @@ -54,38 +56,14 @@ func (q *Queue[T]) Pop() (*T, error) { // It errors out if there is no element or the queue is invalid. func (q *Queue[T]) Top() (*T, error) { if !q.isValid() { - return nil, errors.New("queue invalid") + return nil, ErrInvalidQueue } if q.IsEmpty() { - return nil, errors.New("queue empty") + return nil, ErrEmptyQueue } return q.head.Elem, nil } -// HeadElem returns the first ChainElem of the queue without removing it. -// It errors out if there is no element or the queue is invalid. -func (q *Queue[T]) HeadElem() (*ChainElem[T], error) { - if !q.isValid() { - return nil, errors.New("queue invalid") - } - if q.IsEmpty() { - return nil, errors.New("queue empty") - } - return q.head, nil -} - -// MarshalJSON is used for generating json data when using json.Marshal. -func (q *Queue[T]) MarshalJSON() ([]byte, error) { - if !q.isValid() { - return nil, errors.New("queue invalid") - } - if q.IsEmpty() { - return nil, errors.New("queue empty") - } - - return json.Marshal(q.head) -} - func BuildQueue[T any]() *Queue[T] { empty := emptyElem[T]() return &Queue[T]{ diff --git a/containers/stacks.go b/containers/stacks.go index 57a4b55..101b820 100644 --- a/containers/stacks.go +++ b/containers/stacks.go @@ -1,13 +1,12 @@ package containers import ( - "encoding/json" "errors" ) type Stack[T any] struct { - top *ChainElem[T] - bottom *ChainElem[T] + top *chainElem[T] + bottom *chainElem[T] } // isValid checks if the stack is valid. @@ -61,17 +60,6 @@ func (s *Stack[T]) Top() (*T, error) { return s.top.Elem, nil } -// MarshalJSON is used by json.Marshal to create a json representation. -func (s *Stack[T]) MarshalJSON() ([]byte, error) { - if !s.isValid() { - return nil, errors.New("queue invalid") - } - if s.IsEmpty() { - return nil, errors.New("queue empty") - } - - return json.Marshal(s.top) -} func BuildStack[T any]() *Stack[T] { empty := emptyElem[T]() return &Stack[T]{ From be9ed2adefb39f37a172ce2a5f468d2b4b0dd341 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:57:14 +0200 Subject: [PATCH 06/23] Remove mutex, since no usages --- wrapped-mutex/mutex.go | 30 ------------------------------ 1 file changed, 30 deletions(-) delete mode 100644 wrapped-mutex/mutex.go diff --git a/wrapped-mutex/mutex.go b/wrapped-mutex/mutex.go deleted file mode 100644 index 6563842..0000000 --- a/wrapped-mutex/mutex.go +++ /dev/null @@ -1,30 +0,0 @@ -package wrappedmutex - -import "sync" - -type Mutex[T any] struct { - wrapped T - lock sync.Mutex -} - -func New[T any](wrapped T) Mutex[T] { - return Mutex[T]{ - wrapped: wrapped, - } -} - -func (m *Mutex[T]) Lock() { - m.lock.Lock() -} - -func (m *Mutex[T]) TryLock() bool { - return m.lock.TryLock() -} - -func (m *Mutex[T]) Unlock() { - m.lock.Unlock() -} - -func (m *Mutex[T]) Get() *T { - return &m.wrapped -} From 8bb1797894420b7ef7b07e1b79a17f3e53e60104 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:57:30 +0200 Subject: [PATCH 07/23] Remove multiplexer, since wrong implemented --- multiplexer/many-to-one.go | 43 ---------------- multiplexer/one-to-many.go | 101 ------------------------------------- 2 files changed, 144 deletions(-) delete mode 100644 multiplexer/many-to-one.go delete mode 100644 multiplexer/one-to-many.go diff --git a/multiplexer/many-to-one.go b/multiplexer/many-to-one.go deleted file mode 100644 index 6ea931a..0000000 --- a/multiplexer/many-to-one.go +++ /dev/null @@ -1,43 +0,0 @@ -// Copyright (c) 2024 mStar -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package multiplexer - -import "errors" - -// A many to one multiplexer -// Yes, channels technically already are that, but there are a bunch of problems with using raw channels as multiplexer: -// If any of the senders tries to send to a closed channel, it explodes -// Thus, wrap it inside a struct that handles that case of a closed channel -type ManyToOne[T any] struct { - outbound chan T - closed bool -} - -// NewManyToOne creates a new ManyToOne multiplexer -// The given channel will be where all messages will be sent to -func NewManyToOne[T any](receiver chan T) ManyToOne[T] { - return ManyToOne[T]{ - outbound: receiver, - closed: false, - } -} - -// Send a message to this many to one plexer -// If closed, the message won't get sent -func (m *ManyToOne[T]) Send(msg T) error { - if m.closed { - return errors.New("multiplexer has been closed") - } - m.outbound <- msg - return nil -} - -// Closes the channel and marks the plexer as closed -func (m *ManyToOne[T]) Close() { - close(m.outbound) - m.closed = true -} diff --git a/multiplexer/one-to-many.go b/multiplexer/one-to-many.go deleted file mode 100644 index 95dff4c..0000000 --- a/multiplexer/one-to-many.go +++ /dev/null @@ -1,101 +0,0 @@ -// Copyright (c) 2024 mStar -// -// This Source Code Form is subject to the terms of the Mozilla Public -// License, v. 2.0. If a copy of the MPL was not distributed with this -// file, You can obtain one at http://mozilla.org/MPL/2.0/. - -package multiplexer - -import ( - "errors" - "sync" -) - -type OneToMany[T any] struct { - inbound chan T - outbound map[string]chan T // Use map here to give names to outbound channels - lock sync.Mutex - closeChan chan any - closed bool -} - -func NewOneToMany[T any]() OneToMany[T] { - return OneToMany[T]{ - inbound: make(chan T), - outbound: make(map[string]chan T), - lock: sync.Mutex{}, - closeChan: make(chan any), - closed: false, - } -} - -// Get the channel to send things into -func (o *OneToMany[T]) GetSender() chan T { - return o.inbound -} - -// Create a new receiver for the multiplexer to send messages to. -// Please do not close this manually, instead use the CloseReceiver func -func (o *OneToMany[T]) MakeReceiver(name string) (chan T, error) { - if o.closed { - return nil, errors.New("multiplexer has been closed") - } - rec := make(chan T) - - // Only allow new receivers to be made - o.lock.Lock() - if _, ok := o.outbound[name]; ok { - return nil, errors.New("receiver with that name already exists") - } - o.lock.Unlock() - - o.outbound[name] = rec - - return rec, nil -} - -// Closes a receiver channel with the given name and removes it from the multiplexer -func (o *OneToMany[T]) CloseReceiver(name string) { - if o.closed { - return - } - o.lock.Lock() - if val, ok := o.outbound[name]; ok { - close(val) - delete(o.outbound, name) - } - o.lock.Unlock() -} - -// Start this one to many multiplexer -// intended to run as a goroutine (`go plexer.StartPlexer()`) -func (o *OneToMany[T]) StartPlexer() { - select { - // Message gotten from inbound channel - case msg := <-o.inbound: - o.lock.Lock() - // Send it to all outbound channels - for _, c := range o.outbound { - c <- msg - } - o.lock.Unlock() - // Told to close the plexer including sender - case <-o.closeChan: - o.lock.Lock() - // First close all outbound channels - // No need to send any signal there as readers will just stop - for _, c := range o.outbound { - close(c) - } - // Then close inbound, set closed and call it a day - close(o.inbound) - o.closed = true - o.lock.Unlock() - return - } -} - -// Close the sender and all receiver channels, mark the plexer as closed and stop the distribution goroutine (all by sending one signal) -func (o *OneToMany[T]) CloseSender() { - o.closeChan <- 1 -} From 4013622afc451071193a695b8004cc4cff48d058 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:57:49 +0200 Subject: [PATCH 08/23] Remove logrotate, lumberjack is better --- logrotate/logrotate.go | 67 ------------------------------------------ 1 file changed, 67 deletions(-) delete mode 100644 logrotate/logrotate.go diff --git a/logrotate/logrotate.go b/logrotate/logrotate.go deleted file mode 100644 index ecf4c7f..0000000 --- a/logrotate/logrotate.go +++ /dev/null @@ -1,67 +0,0 @@ -// File wrapper for rotating log files -// Copied from https://stackoverflow.com/a/28797984 -package logrotate - -import ( - "os" - "path" - "sync" - "time" -) - -type RotateWriter struct { - lock sync.Mutex - filename string // should be set to the actual filename - fp *os.File -} - -// Make a new RotateWriter. Return nil if error occurs during setup. -func New(filename string) (*RotateWriter, error) { - w := &RotateWriter{filename: filename} - err := w.Rotate() - if err != nil { - return nil, err - } - return w, nil -} - -// Write satisfies the io.Writer interface. -func (w *RotateWriter) Write(output []byte) (int, error) { - w.lock.Lock() - defer w.lock.Unlock() - return w.fp.Write(output) -} - -// Perform the actual act of rotating and reopening file. -func (w *RotateWriter) Rotate() (err error) { - w.lock.Lock() - defer w.lock.Unlock() - - // Close existing file if open - if w.fp != nil { - err = w.fp.Close() - w.fp = nil - if err != nil { - return - } - } - // Rename dest file if it already exists - _, err = os.Stat(w.filename) - if err == nil { - err = os.Rename(w.filename, w.filename+"."+time.Now().Format(time.RFC3339)) - if err != nil { - return - } - } - - // Create a file. - dir := path.Dir(w.filename) - _, err = os.Stat(dir) - if err != nil { - if err = os.Mkdir(dir, os.ModeDir); err != nil { - return - } - } - w.fp, err = os.Create(w.filename) - return -} From 2b737d056cad774157de26fd978d2313bd68a8ab Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:58:06 +0200 Subject: [PATCH 09/23] Add err returns --- http/httpErr.go | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/http/httpErr.go b/http/httpErr.go index fc87acb..701cfbf 100644 --- a/http/httpErr.go +++ b/http/httpErr.go @@ -12,10 +12,11 @@ import ( // and a field "message" set to the `message` // // Deprecated: Use ProblemDetails or ProblemDetailsStatusOnly instead -func HttpErr(w http.ResponseWriter, errId int, message string, code int) { +func HttpErr(w http.ResponseWriter, errId int, message string, code int) error { w.WriteHeader(code) w.Header().Add("Content-Type", "application/json") - fmt.Fprintf(w, "{\"id\": %d, \"message\": \"%s\"}", errId, message) + _, err := fmt.Fprintf(w, "{\"id\": %d, \"message\": \"%s\"}", errId, message) + return err } // Write an RFC 9457 compliant problem details response @@ -31,7 +32,7 @@ func ProblemDetails( errorTitle string, details *string, extras map[string]any, -) { +) error { w.Header().Add("Content-Type", "application/problem+json") w.WriteHeader(statusCode) data := map[string]any{ @@ -42,22 +43,20 @@ func ProblemDetails( if details != nil { data["detail"] = *details } - if extras != nil { - for k, v := range extras { - if _, ok := data[k]; ok { - // Don't overwrite default fields - continue - } - data[k] = v + for k, v := range extras { + if _, ok := data[k]; ok { + // Don't overwrite default fields + continue } + data[k] = v } enc := json.NewEncoder(w) - enc.Encode(data) + return enc.Encode(data) } // Write a simple problem details response. // It only provides the status code, as defined in RFC 9457, section 4.2.1 -func ProblemDetailsStatusOnly(w http.ResponseWriter, statusCode int) { +func ProblemDetailsStatusOnly(w http.ResponseWriter, statusCode int) error { w.Header().Add("Content-Type", "application/problem+json") w.WriteHeader(statusCode) data := map[string]any{ @@ -68,5 +67,5 @@ func ProblemDetailsStatusOnly(w http.ResponseWriter, statusCode int) { } enc := json.NewEncoder(w) - enc.Encode(data) + return enc.Encode(data) } From 695b48ae91786e1e44c9124edc2ad8ba58e79919 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:58:21 +0200 Subject: [PATCH 10/23] More error returns added --- http/json.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/http/json.go b/http/json.go index ac6e4ae..b3a9424 100644 --- a/http/json.go +++ b/http/json.go @@ -4,14 +4,17 @@ import ( "encoding/json" "fmt" "net/http" + + "git.mstar.dev/mstar/goutils/other" ) func SendJson(w http.ResponseWriter, data any) error { encoded, err := json.Marshal(data) if err != nil { - return err + return other.Error("webutils.SendJson", "failed to marshal data to json", err) } w.Header().Add("Content-Type", "application/json") - fmt.Fprint(w, string(encoded)) + // Can't really catch here + _, _ = fmt.Fprint(w, string(encoded)) return nil } From 99b00887a8d19bd0fb8b0bc0d37b833139802f59 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:58:32 +0200 Subject: [PATCH 11/23] Apply some guy's law about logic operation reordering --- maputils/mapUtils.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/maputils/mapUtils.go b/maputils/mapUtils.go index 8177dd1..a9db4d5 100644 --- a/maputils/mapUtils.go +++ b/maputils/mapUtils.go @@ -65,7 +65,7 @@ func CompareMap[K, V comparable](a, b map[K]V) bool { // Then compare key-value pairs for k, v := range a { val, ok := b[k] - if !(ok && val == v) { + if !ok || val != v { return false } } From ccf98c2f6e4b8f6023491c9bacec7c40795481d3 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 15:59:15 +0200 Subject: [PATCH 12/23] Shit ton of tests --- containers/queues_test.go | 95 ++++++++++++ containers/stacks_test.go | 108 +++++++++++++ coverage_badge.png | Bin 0 -> 2502 bytes embedFsWrapper/wrapper_test.go | 27 ++++ http/chain_test.go | 33 ++++ http/context_test.go | 23 +++ http/httpErr_test.go | 235 ++++++++++++++++++++++++++++ http/json_test.go | 45 ++++++ maputils/mapUtils_test.go | 274 +++++++++++++++++++++++++++++++++ math/math_test.go | 16 ++ other/other_test.go | 55 +++++++ other/zerolog.go | 4 +- sliceutils/sliceUtils_test.go | 243 +++++++++++++++++++++++++++++ 13 files changed, 1157 insertions(+), 1 deletion(-) create mode 100644 containers/queues_test.go create mode 100644 containers/stacks_test.go create mode 100644 coverage_badge.png create mode 100644 embedFsWrapper/wrapper_test.go create mode 100644 http/chain_test.go create mode 100644 http/context_test.go create mode 100644 http/httpErr_test.go create mode 100644 http/json_test.go create mode 100644 maputils/mapUtils_test.go create mode 100644 math/math_test.go create mode 100644 other/other_test.go create mode 100644 sliceutils/sliceUtils_test.go diff --git a/containers/queues_test.go b/containers/queues_test.go new file mode 100644 index 0000000..997a407 --- /dev/null +++ b/containers/queues_test.go @@ -0,0 +1,95 @@ +package containers_test + +import ( + "testing" + + "git.mstar.dev/mstar/goutils/containers" + "git.mstar.dev/mstar/goutils/other" +) + +func TestQueue(t *testing.T) { + queue := containers.BuildQueue[int]() + elem, err := queue.Top() + switch err { + case containers.ErrEmptyQueue: + // Expected, continue + case containers.ErrInvalidQueue: + t.Fatal("New queue is invalid") + case nil: + t.Fatalf("New queue shouldn't have any elements yet: %v", elem) + } + elem, err = queue.Pop() + switch err { + case containers.ErrEmptyQueue: + // Expected, continue + case containers.ErrInvalidQueue: + t.Fatal("New queue is invalid") + case nil: + t.Fatalf("New queue shouldn't have any elements yet: %v", elem) + } + if !queue.IsEmpty() { + t.Fatal("New queue isn't empty") + } + + if err = queue.Push(other.IntoPointer(10)); err != nil { + t.Fatal("Failed to push value onto new queue") + } + if queue.IsEmpty() { + t.Fatal("Queue is empty after pushing a value") + } + + if err = queue.Push(other.IntoPointer(20)); err != nil { + t.Fatal("Failed to push value onto queue") + } + + elem, err = queue.Top() + if err != nil { + t.Fatalf("Failed to get top element from queue after inserting one: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &10") + case *elem == 10: + // Expected, continue + default: + t.Fatalf("Got %v; want &10", *elem) + } + + if queue.IsEmpty() { + t.Fatal("Top shouldn't pop an element from the queue") + } + + elem, err = queue.Pop() + if err != nil { + t.Fatalf("Expected no error while popping a value: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &10") + case *elem == 10: + // Expected, continue + default: + t.Fatalf("Got %v; want &10", *elem) + } + + if queue.IsEmpty() { + t.Fatal("Queue should still have one element left") + } + + elem, err = queue.Pop() + if err != nil { + t.Fatalf("Expected no error while popping a value: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &20") + case *elem == 20: + // Expected, continue + default: + t.Fatalf("Got %v; want &20", *elem) + } + + if !queue.IsEmpty() { + t.Fatal("Queue should be empty now") + } +} diff --git a/containers/stacks_test.go b/containers/stacks_test.go new file mode 100644 index 0000000..7afc85a --- /dev/null +++ b/containers/stacks_test.go @@ -0,0 +1,108 @@ +package containers_test + +import ( + "testing" + + "git.mstar.dev/mstar/goutils/containers" + "git.mstar.dev/mstar/goutils/other" +) + +func TestStack(t *testing.T) { + stack := containers.BuildStack[int]() + elem, err := stack.Top() + switch err { + case containers.ErrEmptyQueue: + // Expected, continue + case containers.ErrInvalidQueue: + t.Fatal("New queue is invalid") + case nil: + t.Fatalf("New queue shouldn't have any elements yet: %v", elem) + } + elem, err = stack.Pop() + switch err { + case containers.ErrEmptyQueue: + // Expected, continue + case containers.ErrInvalidQueue: + t.Fatal("New queue is invalid") + case nil: + t.Fatalf("New queue shouldn't have any elements yet: %v", elem) + } + if !stack.IsEmpty() { + t.Fatal("New queue isn't empty") + } + + if err = stack.Push(other.IntoPointer(10)); err != nil { + t.Fatal("Failed to push value onto new queue") + } + if stack.IsEmpty() { + t.Fatal("Queue is empty after pushing a value") + } + + elem, err = stack.Top() + if err != nil { + t.Fatalf("Failed to get top element from queue after inserting one: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &10") + case *elem == 10: + // Expected, continue + default: + t.Fatalf("Got %v; want &10", *elem) + } + + if err = stack.Push(other.IntoPointer(20)); err != nil { + t.Fatal("Failed to push value onto queue") + } + + elem, err = stack.Top() + if err != nil { + t.Fatalf("Failed to get top element from queue after inserting one: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &20") + case *elem == 20: + // Expected, continue + default: + t.Fatalf("Got %v; want &20", *elem) + } + + if stack.IsEmpty() { + t.Fatal("Top shouldn't pop an element from the queue") + } + + elem, err = stack.Pop() + if err != nil { + t.Fatalf("Expected no error while popping a value: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &20") + case *elem == 20: + // Expected, continue + default: + t.Fatalf("Got %v; want &20", *elem) + } + + if stack.IsEmpty() { + t.Fatal("Queue should still have one element left") + } + + elem, err = stack.Pop() + if err != nil { + t.Fatalf("Expected no error while popping a value: %v", err) + } + switch { + case elem == nil: + t.Fatal("Got nil elem; want &10") + case *elem == 10: + // Expected, continue + default: + t.Fatalf("Got %v; want &10", *elem) + } + + if !stack.IsEmpty() { + t.Fatal("Queue should be empty now") + } +} diff --git a/coverage_badge.png b/coverage_badge.png new file mode 100644 index 0000000000000000000000000000000000000000..0a69742bed71640a7543f71c62a6a330162374a2 GIT binary patch literal 2502 zcmV;%2|4zOP)~{MLLWqq*h&x);w^>tQ?3@y8RMYN4F$|;TPFM47*Ay^x zOhT+tzFTI$xu%SvU{|J4G2dEEr7^rELuFY;(=_~kKYqXe8zod#m87I3yk0M%Q0N=6 zrix;XVVucAT3iO6(W?aXkqPG&NkJDng6McnO~t1V5i&+^yOv1}HJi;;S68!W(IWc# z`mkE92q8Fj>=?(7AHS_c@$vCgS68!e;X?ZR`$zx{V39V2HV$Df?J50yAFt+~8(p@}oz zPXO3H`yhYnZl-;tZPHpMHFW*@^#~!Tsi`3t3}P4txw*NNl$1nuLRD2XO~d2y#GI`S z4Go0DVJa&t357xkAy~0u1s01X5_2n@@seekY15|B*Vl)x>oMC&l0;Hc5|Sh_I5;?g zuc|7^$;r6g?x=IO!XM9ym#wV--fvj>hkNnMUaa9{{?^dOyRO&p>%){fDk;loz!^7# zu+NCZr^RJ(mw6749x3LBj-@#x5a`M-Sma>~le=aJgJ$W@d8m z;6d8j+GuTU<<(bTW!0)xba!{NYu7IJ?b}B%7(~}~tX3<#ckiaBrw4!~OO{YsSxIki zFNukX$g<3a4I5(eZ)T{W3&MJs;vYT0Kl|Sy#bm`lI?QM&h>!$FPrpX%yRUG#_Fo7@ zBtA}0V8}m6(%lMSX@qz^0U-pnS)2LY2TvnAq?=Ckn1)WDKAq0aPGnh*95b0rn9XKX zRmCt2oK7d@<>jnfw~mpK5ef?ndHnIm*|u$46e}|`ldi6=2g($X1Ofn5S68!a*)m#MT1Zb%r>d%o`uchX2M5W{&gS{&pC>IXZBqVZhWhmp zwjTW%dsiRg%;3i;lFHVj^^DqsNG6C&HxZX^0%P91bJPGLkelD@sdCdE}8tP!xr(t}dQ=<{9$x z@;H6^G-k7z`1p8EojOHMP7Y?XnP4y&Vd=V#BuNq0c*WuXoIH7wg$oz*{`>D!Qc}Wm z&pijg%$YM$Rh1n(b|6U-hGCGEl@(#W_10ThES5;lWy_XPQc^-%S{hcXm9uBhB7}(K zHknMMrlv+#p2_&*`!Heomd*Tp*;8!%^BRK62z3R&q_OmAo@%R@_@E>H8y{AKZ24;) zWs9n~xF+cUJYvy+^h9BeilLqkKn z@x~h*I&_Glq9UrQst`gD4u_+RijR*6z%Y!cPF}otk=)!|EEWqspKn60SR8=2-+r6= z`g$&1y2QZ10N1WvLs1lTU8lXho&EdwN7@F1!N@9LwOS*`^YinmuC8XsjvaJ#bg*>k zQi_X<5kjErdep?dUiZ!TQJ#z&!?{`^p@7ck<`0l$*z)&U{<7vX=4wTJ(ii7@y@^AKYxi2$a%Oxc> z1;r+jc5fnPIi8+t7o*z2#8}w^))_Jur%Y95BQ0n4BAH=0Fhq(y2}`P(upY+H^&94v zRz~k{mX6NCTM}-n@BuJf6t5rMbBox7$4_XZiBw)YQ~aS67FkC=p(Mem;$jjSLJ7Fm2j2 zIy*brwQCngjvS$)q9T%8mSy(r*@G<0D2hT)PY+(Nm;L+qqw6~N-+w=kJ@y!eVesjv zpE7^`d^T;`6q7$*<6q8)eS%7D6RYPx%9lf*k&`f+BOkSJ%y$skD)CU(S{}?=N4Dkr zTn${J(|3+%Kd+BsEOo3V!#|5xJkMiNB??0o{B*%u0*0TD&Ya-Q@P2M2>EVYTj%lb6 zf}x=y0)YSyhXbF_$MEoQc1Y2rRe2!UZ3H@v^ZVwp^)D099J=ei8Th}?s}PTr=bCWH`#LLp|% zn8Eh#+wpijx6F_A`AisKjE4+C5bQyz@)@(BbAkvO`R8!cSwd|ke!`PC=}xS z`SVz<*2vO+E4tI)L%%9T2;l;pH*NVpEFlCP9UVxLgw1A)xxJb!eo9D^t|&mO5Mtw< zuH{ZjlBB5n{_PqxRScDoT9r^Jv{g|Qgb>yIpI@n8S5v@H7n0PPp!fayKOWU6Y-{J- QMgRZ+07*qoM6N<$g23>|0RR91 literal 0 HcmV?d00001 diff --git a/embedFsWrapper/wrapper_test.go b/embedFsWrapper/wrapper_test.go new file mode 100644 index 0000000..aeba3a4 --- /dev/null +++ b/embedFsWrapper/wrapper_test.go @@ -0,0 +1,27 @@ +package embedFsWrapper_test + +import ( + "io" + "testing" + "testing/fstest" + + "git.mstar.dev/mstar/goutils/embedFsWrapper" +) + +func TestFSWrapper(t *testing.T) { + mfs := fstest.MapFS{ + "baz/foo": &fstest.MapFile{Data: []byte("bar")}, + } + wrapped := embedFsWrapper.NewFSWrapper(mfs, "baz/") + f, err := wrapped.Open("foo") + if err != nil { + t.Fatalf("Expected to open file foo (baz/foo), got %v", err) + } + data, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + if string(data) != "bar" { + t.Fatalf("Expected file to have \"bar\" as content, found %v", string(data)) + } +} diff --git a/http/chain_test.go b/http/chain_test.go new file mode 100644 index 0000000..71958a8 --- /dev/null +++ b/http/chain_test.go @@ -0,0 +1,33 @@ +package webutils_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + webutils "git.mstar.dev/mstar/goutils/http" +) + +func TestChainMiddlewares(t *testing.T) { + wrapper1 := webutils.HandlerBuilder(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Add("X-Foo", "bar") + h.ServeHTTP(w, r) + }) + }) + wrapper2 := webutils.HandlerBuilder(func(h http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.Header.Add("X-Foos", r.Header.Get("X-Foo")+"baz") + h.ServeHTTP(w, r) + }) + }) + baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if x := r.Header.Get("X-Foos"); x != "barbaz" { + t.Fatalf(`Expected header "X-Foos" to be "barbaz", got %v`, x) + } + }) + handler := webutils.ChainMiddlewares(baseHandler, wrapper1, wrapper2) + recorder := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + handler.ServeHTTP(recorder, req) +} diff --git a/http/context_test.go b/http/context_test.go new file mode 100644 index 0000000..86026b1 --- /dev/null +++ b/http/context_test.go @@ -0,0 +1,23 @@ +package webutils_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + webutils "git.mstar.dev/mstar/goutils/http" +) + +func TestContextValsMiddleware(t *testing.T) { + builder := webutils.ContextValsMiddleware(map[any]any{"foo": "bar"}) + baseHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if val, _ := r.Context().Value("foo").(string); val != "bar" { + t.Fatalf(`Expected context value "foo" to be "bar", got %v`, val) + } + }) + wrapped := builder(baseHandler) + recorder := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/", nil) + wrapped.ServeHTTP(recorder, req) + // At this point baseHandler should have been called and checked for the context value +} diff --git a/http/httpErr_test.go b/http/httpErr_test.go new file mode 100644 index 0000000..75dfce7 --- /dev/null +++ b/http/httpErr_test.go @@ -0,0 +1,235 @@ +package webutils_test + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + webutils "git.mstar.dev/mstar/goutils/http" + "git.mstar.dev/mstar/goutils/maputils" + "git.mstar.dev/mstar/goutils/other" + "git.mstar.dev/mstar/goutils/sliceutils" +) + +func TestHttpErr(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.HttpErr(recorder, 1, "some error", 500) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + res := recorder.Result() + if res.StatusCode != 500 { + t.Fatalf("Expected status code 500, got %v", res.StatusCode) + } + body := string(other.Must(io.ReadAll(res.Body))) + expected := `{"id": 1, "message": "some error"}` + if body != expected { + t.Fatalf("Expected body %q, got %q", expected, body) + } +} + +func TestProblemDetailsStatusOnly(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.ProblemDetailsStatusOnly(recorder, 500) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + res := recorder.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" { + t.Fatalf("Expected content type to be \"application/problem+json\" got %v", ct) + } + body := string(other.Must(io.ReadAll(res.Body))) + data := map[string]any{} + err = json.Unmarshal([]byte(body), &data) + if err != nil { + t.Fatalf("response body is not json: %v", err) + } + if dt, ok := data["type"].(string); !ok || dt != "about:blank" { + t.Fatalf(`Expected key "type" as "about:blank", got %v`, dt) + } + if dt, ok := data["title"].(string); !ok || dt != http.StatusText(500) { + t.Fatalf(`Expected key "title" as "%v", got %v`, http.StatusText(500), dt) + } + if dt, ok := data["status"].(float64); !ok || dt != 500 { + t.Fatalf(`Expected key "status" as 500, got %v`, dt) + } + if dt, ok := data["reference"].(string); !ok || dt != "RFC 9457" { + t.Fatalf(`Expected key "reference" as "RFC 9457", got %v`, dt) + } +} + +func TestProblemDetailsNoDetailsOrExtras(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.ProblemDetails( + recorder, + 500, + "error-type", + "error-title", + nil, + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + res := recorder.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" { + t.Fatalf("Expected content type to be \"application/problem+json\" got %v", ct) + } + body := string(other.Must(io.ReadAll(res.Body))) + data := map[string]any{} + err = json.Unmarshal([]byte(body), &data) + if err != nil { + t.Fatalf("response body is not json: %v", err) + } + + expectedFields := []string{"type", "title", "status"} + gotFields := maputils.KeysFromMap(data) + if !sliceutils.CompareUnordered(gotFields, expectedFields) { + t.Fatalf("Expected fields %v, got %v", expectedFields, gotFields) + } + if dt, ok := data["type"].(string); !ok || dt != "error-type" { + t.Fatalf(`Expected key "type" as "error-type", got %v`, dt) + } + if dt, ok := data["title"].(string); !ok || dt != "error-title" { + t.Fatalf(`Expected key "title" as "error-title", got %v`, dt) + } + if dt, ok := data["status"].(float64); !ok || dt != 500 { + t.Fatalf(`Expected key "status" as 500, got %v`, dt) + } +} + +func TestProblemDetailsNoDetailsWithExtras(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.ProblemDetails( + recorder, + 500, + "error-type", + "error-title", + nil, + map[string]any{"foo": "bar"}, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + res := recorder.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" { + t.Fatalf("Expected content type to be \"application/problem+json\" got %v", ct) + } + body := string(other.Must(io.ReadAll(res.Body))) + data := map[string]any{} + err = json.Unmarshal([]byte(body), &data) + if err != nil { + t.Fatalf("response body is not json: %v", err) + } + + expectedFields := []string{"type", "title", "status", "foo"} + gotFields := maputils.KeysFromMap(data) + if !sliceutils.CompareUnordered(gotFields, expectedFields) { + t.Fatalf("Expected fields %v, got %v", expectedFields, gotFields) + } + if dt, ok := data["type"].(string); !ok || dt != "error-type" { + t.Fatalf(`Expected key "type" as "error-type", got %v`, dt) + } + if dt, ok := data["title"].(string); !ok || dt != "error-title" { + t.Fatalf(`Expected key "title" as "error-title", got %v`, dt) + } + if dt, ok := data["status"].(float64); !ok || dt != 500 { + t.Fatalf(`Expected key "status" as 500, got %v`, dt) + } + if dt, ok := data["foo"].(string); !ok || dt != "bar" { + t.Fatalf(`Expected key "foo" as "bar", got %v`, dt) + } +} + +func TestProblemDetailsWithDetailsNoExtras(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.ProblemDetails( + recorder, + 500, + "error-type", + "error-title", + other.IntoPointer("error-details"), + nil, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + + res := recorder.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" { + t.Fatalf("Expected content type to be \"application/problem+json\" got %v", ct) + } + body := string(other.Must(io.ReadAll(res.Body))) + data := map[string]any{} + err = json.Unmarshal([]byte(body), &data) + if err != nil { + t.Fatalf("response body is not json: %v", err) + } + + expectedFields := []string{"type", "title", "status", "detail"} + gotFields := maputils.KeysFromMap(data) + if !sliceutils.CompareUnordered(gotFields, expectedFields) { + t.Fatalf("Expected fields %v, got %v", expectedFields, gotFields) + } + if dt, ok := data["type"].(string); !ok || dt != "error-type" { + t.Fatalf(`Expected key "type" as "error-type", got %v`, dt) + } + if dt, ok := data["title"].(string); !ok || dt != "error-title" { + t.Fatalf(`Expected key "title" as "error-title", got %v`, dt) + } + if dt, ok := data["status"].(float64); !ok || dt != 500 { + t.Fatalf(`Expected key "status" as 500, got %v`, dt) + } + if dt, ok := data["detail"].(string); !ok || dt != "error-details" { + t.Fatalf(`Expected key "detail" as "error-details", got %v`, dt) + } +} + +func TestProblemDetailsWithDetailsWithExtras(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.ProblemDetails( + recorder, + 500, + "error-type", + "error-title", + other.IntoPointer("error-details"), + map[string]any{"foo": "bar", "type": "should not be set in the end"}, + ) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + res := recorder.Result() + if ct := res.Header.Get("Content-Type"); ct != "application/problem+json" { + t.Fatalf("Expected content type to be \"application/problem+json\" got %v", ct) + } + body := string(other.Must(io.ReadAll(res.Body))) + data := map[string]any{} + err = json.Unmarshal([]byte(body), &data) + if err != nil { + t.Fatalf("response body is not json: %v", err) + } + + expectedFields := []string{"type", "title", "status", "detail", "foo"} + gotFields := maputils.KeysFromMap(data) + if !sliceutils.CompareUnordered(gotFields, expectedFields) { + t.Fatalf("Expected fields %v, got %v", expectedFields, gotFields) + } + if dt, ok := data["type"].(string); !ok || dt != "error-type" { + t.Fatalf(`Expected key "type" as "error-type", got %v`, dt) + } + if dt, ok := data["title"].(string); !ok || dt != "error-title" { + t.Fatalf(`Expected key "title" as "error-title", got %v`, dt) + } + if dt, ok := data["status"].(float64); !ok || dt != 500 { + t.Fatalf(`Expected key "status" as 500, got %v`, dt) + } + if dt, ok := data["detail"].(string); !ok || dt != "error-details" { + t.Fatalf(`Expected key "detail" as "error-details", got %v`, dt) + } + if dt, ok := data["foo"].(string); !ok || dt != "bar" { + t.Fatalf(`Expected key "foo" as "bar", got %v`, dt) + } +} diff --git a/http/json_test.go b/http/json_test.go new file mode 100644 index 0000000..23236f8 --- /dev/null +++ b/http/json_test.go @@ -0,0 +1,45 @@ +package webutils_test + +import ( + "io" + "net/http/httptest" + "testing" + + webutils "git.mstar.dev/mstar/goutils/http" +) + +func TestSendJsonOk(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.SendJson(recorder, map[string]any{ + "a": 1, + "b": "b", + "c": nil, + }) + if err != nil { + t.Fatalf("error while sending json: %v", err) + } + res := recorder.Result() + if ctype := res.Header.Get("Content-Type"); ctype != "application/json" { + t.Fatalf("expected content type \"application/json\", got %v", ctype) + } + // httptest guarantees body to be functional and only returned error will be EOL + // so no need to watch for error + bodyBytes, _ := io.ReadAll(res.Body) + body := string(bodyBytes) + expected := `{"a":1,"b":"b","c":null}` + if body != expected { + t.Fatalf("Expected %v as body, got %v", expected, body) + } +} + +func TestSendJsonNotMarshallable(t *testing.T) { + recorder := httptest.NewRecorder() + err := webutils.SendJson(recorder, map[string]any{ + "a": 1, + "b": "b", + "c": make(chan any), + }) + if err == nil { + t.Fatal("expected json marshalling error") + } +} diff --git a/maputils/mapUtils_test.go b/maputils/mapUtils_test.go new file mode 100644 index 0000000..61191f0 --- /dev/null +++ b/maputils/mapUtils_test.go @@ -0,0 +1,274 @@ +package maputils_test + +import ( + "reflect" + "testing" + + "git.mstar.dev/mstar/goutils/maputils" + "git.mstar.dev/mstar/goutils/sliceutils" +) + +func TestMapSameKeys(t *testing.T) { + type args struct { + dic map[string]int + apply func(string, int) int + } + tests := []struct { + name string + args args + want map[string]int + }{ + { + name: "map", + args: args{ + dic: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + apply: func(s string, i int) int { return i + 1 }, + }, + want: map[string]int{ + "a": 2, + "b": 3, + "c": 4, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := maputils.MapSameKeys(tt.args.dic, tt.args.apply); !reflect.DeepEqual( + got, + tt.want, + ) { + t.Errorf("MapSameKeys() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMapNewKeys(t *testing.T) { + type args struct { + in map[string]int + apply func(string, int) (string, int) + } + tests := []struct { + name string + args args + want map[string]int + }{ + { + name: "new keys", + args: args{ + in: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + apply: func(s string, i int) (string, int) { return s + "-", i + 1 }, + }, + want: map[string]int{ + "a-": 2, + "b-": 3, + "c-": 4, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := maputils.MapNewKeys(tt.args.in, tt.args.apply); !reflect.DeepEqual( + got, + tt.want, + ) { + t.Errorf("MapNewKeys() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFilterMap(t *testing.T) { + type args struct { + dic map[string]int + filter func(string, int) bool + } + tests := []struct { + name string + args args + want map[string]int + }{ + { + name: "filter", + args: args{ + dic: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + filter: func(s string, i int) bool { return s == "a" }, + }, + want: map[string]int{"a": 1}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := maputils.FilterMap(tt.args.dic, tt.args.filter); !reflect.DeepEqual( + got, + tt.want, + ) { + t.Errorf("FilterMap() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestKeysFromMap(t *testing.T) { + type args struct { + m map[string]int + } + tests := []struct { + name string + args args + want []string + }{ + { + name: "keys", + args: args{ + m: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + }, + want: []string{"a", "b", "c"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := maputils.KeysFromMap(tt.args.m); !sliceutils.CompareUnordered(got, tt.want) { + t.Errorf("KeysFromMap() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompareMap(t *testing.T) { + type args struct { + a map[string]int + b map[string]int + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "equal", + args: args{ + a: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + b: map[string]int{ + "b": 2, + "a": 1, + "c": 3, + }, + }, + want: true, + }, + { + name: "not equal, same len, same keys", + args: args{ + a: map[string]int{ + "a": 5, + "b": 6, + "c": 7, + }, + b: map[string]int{ + "a": 1, + "b": 2, + "c": 3, + }, + }, + want: false, + }, + { + name: "not equal, diff len", + args: args{ + a: map[string]int{ + "a": 5, + "b": 6, + "c": 7, + }, + b: map[string]int{ + "a": 1, + "b": 2, + }, + }, + want: false, + }, + { + name: "not equal, same len, diff keys", + args: args{ + a: map[string]int{ + "a": 5, + "b": 6, + "c": 7, + }, + b: map[string]int{ + "e": 1, + "f": 2, + "g": 3, + }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := maputils.CompareMap(tt.args.a, tt.args.b); got != tt.want { + t.Errorf("CompareMap() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompact(t *testing.T) { + type args struct { + m map[int]int + compactor func(accK int, accV int, nextK int, nextV int) (int, int) + } + tests := []struct { + name string + args args + want int + want1 int + }{ + // TODO: Add test cases. + { + name: "compact", + args: args{ + m: map[int]int{ + 4: 1, + 5: 2, + 6: 3, + }, + compactor: func(accK int, accV int, nextK int, nextV int) (int, int) { return accK + nextK, accV + nextV }, + }, + want: 4 + 5 + 6, + want1: 1 + 2 + 3, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, got1 := maputils.Compact(tt.args.m, tt.args.compactor) + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Compact() got = %v, want %v", got, tt.want) + } + if !reflect.DeepEqual(got1, tt.want1) { + t.Errorf("Compact() got1 = %v, want %v", got1, tt.want1) + } + }) + } +} diff --git a/math/math_test.go b/math/math_test.go new file mode 100644 index 0000000..effee83 --- /dev/null +++ b/math/math_test.go @@ -0,0 +1,16 @@ +package mathutils_test + +import ( + "testing" + + mathutils "git.mstar.dev/mstar/goutils/math" +) + +func TestAbs(t *testing.T) { + if x := mathutils.Abs(-5); x != 5 { + t.Fatalf("Received %v, wanted 5", x) + } + if x := mathutils.Abs(6); x != 6 { + t.Fatalf("Received %v, wanted 6", x) + } +} diff --git a/other/other_test.go b/other/other_test.go new file mode 100644 index 0000000..f543121 --- /dev/null +++ b/other/other_test.go @@ -0,0 +1,55 @@ +package other_test + +import ( + "errors" + "testing" + + "git.mstar.dev/mstar/goutils/other" +) + +func TestOutputIntoChannel(t *testing.T) { + c := make(chan int) + go func() { + other.OutputIntoChannel(10, c) + }() + x := <-c + if x != 10 { + t.Fatalf("sent %v, expected 10", x) + } +} + +func TestMust_mustPanic(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Fatal("Must didn't panic on error") + } + }() + _ = other.Must(10, errors.New("some error")) +} + +func TestMust_noPanic(t *testing.T) { + defer func() { + if r := recover(); r != nil { + t.Fatalf("Must panicked: %v", r) + } + }() + x := other.Must(10, nil) + if x != 10 { + t.Fatalf("got %v, expected 10", x) + } +} + +func TestIntoPointer(t *testing.T) { + p := other.IntoPointer(10) + if *p != 10 { + t.Fatalf("other.IntoPointer(10) = %v, want &10", p) + } +} + +func TestError(t *testing.T) { + e := errors.New("some error") + we := other.Error("source", "message", e) + if we.Error() != "source: message: some error" { + t.Fatalf("Got %v, expected \"source: message: some error\"", we) + } +} diff --git a/other/zerolog.go b/other/zerolog.go index 12adec1..b2da351 100644 --- a/other/zerolog.go +++ b/other/zerolog.go @@ -1,5 +1,8 @@ package other +// Can't really test zerolog setup functions as they rely on +// CLI flags + import ( "flag" "io" @@ -72,5 +75,4 @@ func configOutputs(logWriter io.Writer) { append([]io.Writer{log.Logger}, extraLogWriters...)..., )).With().Timestamp().Logger() } - return } diff --git a/sliceutils/sliceUtils_test.go b/sliceutils/sliceUtils_test.go new file mode 100644 index 0000000..107b0ae --- /dev/null +++ b/sliceutils/sliceUtils_test.go @@ -0,0 +1,243 @@ +package sliceutils_test + +import ( + "reflect" + "testing" + + "git.mstar.dev/mstar/goutils/sliceutils" +) + +func TestMap(t *testing.T) { + in := []int8{1, 2, 3, 4} + out := []int16{2, 4, 6, 8} + got := sliceutils.Map(in, func(t int8) int16 { return int16(t) * 2 }) + if !reflect.DeepEqual(out, got) { + t.Fatalf("Map() = %v, want %v", got, out) + } +} + +func TestFilter(t *testing.T) { + in := []int8{1, 2, 3, 4} + out := []int8{2, 4} + got := sliceutils.Filter(in, func(t int8) bool { return t%2 == 0 }) + if !reflect.DeepEqual(out, got) { + t.Fatalf("Map() = %v, want %v", got, out) + } +} + +func TestRemoveDuplicate(t *testing.T) { + in := []int8{1, 2, 2, 3, 3, 3, 4} + out := []int8{1, 2, 3, 4} + got := sliceutils.RemoveDuplicate(in) + if !reflect.DeepEqual(out, got) { + t.Fatalf("Map() = %v, want %v", got, out) + } +} + +func TestReverse(t *testing.T) { + in := []int8{1, 2, 3, 4} + out := []int8{4, 3, 2, 1} + sliceutils.Reverse(in) + if !reflect.DeepEqual(out, in) { + t.Fatalf("Map() = %v, want %v", in, out) + } +} + +func TestCompareOrdered(t *testing.T) { + type args struct { + a []int + b []int + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "equal", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{1, 2, 3, 4}, + }, + want: true, + }, + { + name: "not equal, same len", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{0, 1, 2, 3}, + }, + want: false, + }, + { + name: "not equal, diff len", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{0, 1, 2}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sliceutils.CompareOrdered(tt.args.a, tt.args.b); got != tt.want { + t.Errorf("CompareOrdered() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompareUnordered(t *testing.T) { + type args struct { + a []int + b []int + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "equal ordered", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{1, 2, 3, 4}, + }, + want: true, + }, + { + name: "equal unordered", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{2, 4, 3, 1}, + }, + want: true, + }, + { + name: "not equal, same len", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{0, 2, 3, 4}, + }, + want: false, + }, + { + name: "not equal, diff len", + args: args{ + a: []int{1, 2, 3, 4}, + b: []int{0, 1, 2}, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sliceutils.CompareUnordered(tt.args.a, tt.args.b); got != tt.want { + t.Errorf("CompareUnordered() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContains(t *testing.T) { + type args struct { + a []int + b int + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "contains", + args: args{ + a: []int{1, 2, 3, 4}, + b: 3, + }, + want: true, + }, + { + name: "doesnt contain", + args: args{ + a: []int{1, 2, 3, 4}, + b: 0, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sliceutils.Contains(tt.args.a, tt.args.b); got != tt.want { + t.Errorf("Contains() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestContainsFunc(t *testing.T) { + type args struct { + a []int + f func(t int) bool + } + tests := []struct { + name string + args args + want bool + }{ + { + name: "contains", + args: args{ + a: []int{1, 2, 3, 4}, + f: func(t int) bool { return t == 2 }, + }, + want: true, + }, + { + name: "doesnt contain", + args: args{ + a: []int{1, 2, 3, 4}, + f: func(t int) bool { return t == 9 }, + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sliceutils.ContainsFunc(tt.args.a, tt.args.f); got != tt.want { + t.Errorf("ContainsFunc() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompact(t *testing.T) { + type args struct { + a []int + compactor func(acc int, next int) int + } + tests := []struct { + name string + args args + want int + }{ + { + name: "sum", + args: args{ + a: []int{1, 2, 3, 4}, + compactor: func(acc, next int) int { return acc + next }, + }, + want: 1 + 2 + 3 + 4, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sliceutils.Compact(tt.args.a, tt.args.compactor); !reflect.DeepEqual( + got, + tt.want, + ) { + t.Errorf("Compact() = %v, want %v", got, tt.want) + } + }) + } +} From 92e881b3e48ee29bac35a5e14446c1c3f8fdd912 Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 16:01:37 +0200 Subject: [PATCH 13/23] Add coverage badge --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 95c19df..b3fac6d 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ # GoUtils +![Go Coverage](https://git.mstar.dev/mstar/goutils/src/branch/main/coverage_badge.png) + A collection of useful functions and structs that I use for my projects. From 8156b5b8ebf8c401110ee02cebdcf2c6fd8a2d7f Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 16:23:30 +0200 Subject: [PATCH 14/23] Update readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b3fac6d..e7680f5 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # GoUtils -![Go Coverage](https://git.mstar.dev/mstar/goutils/src/branch/main/coverage_badge.png) +![Go Coverage](https://git.mstar.dev/mstar/goutils/src/branch/main/coverage_badge.png) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://git.mstar.dev/mstar/goutils/src/branch/main/LICENSE) A collection of useful functions and structs that I use for my projects. From fa857615dd3a94191345c28ecf948814ad3e0a0b Mon Sep 17 00:00:00 2001 From: mstar Date: Thu, 24 Apr 2025 16:24:16 +0200 Subject: [PATCH 15/23] Maybe fix cov img --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7680f5..7c00d5e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # GoUtils -![Go Coverage](https://git.mstar.dev/mstar/goutils/src/branch/main/coverage_badge.png) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://git.mstar.dev/mstar/goutils/src/branch/main/LICENSE) +![Go Coverage](https://git.mstar.dev/mstar/goutils/raw/branch/main/coverage_badge.png) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://git.mstar.dev/mstar/goutils/src/branch/main/LICENSE) A collection of useful functions and structs that I use for my projects. From 4b8a62ba12fc7ef2613c4f6c292d6eeca6d92787 Mon Sep 17 00:00:00 2001 From: mstar Date: Mon, 28 Apr 2025 08:49:13 +0200 Subject: [PATCH 16/23] Add some more documentation and links to godoc# --- README.md | 5 ++++- containers/generics.go | 1 + embedFsWrapper/wrapper.go | 2 ++ http/chain.go | 2 ++ maputils/mapUtils.go | 1 + math/math.go | 1 + other/other.go | 1 + sliceutils/sliceUtils.go | 1 + 8 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 7c00d5e..fb2c6ce 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # GoUtils -![Go Coverage](https://git.mstar.dev/mstar/goutils/raw/branch/main/coverage_badge.png) [![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://git.mstar.dev/mstar/goutils/src/branch/main/LICENSE) +![Go Coverage](https://git.mstar.dev/mstar/goutils/raw/branch/main/coverage_badge.png) +[![license](http://img.shields.io/badge/license-MIT-red.svg?style=flat)](https://git.mstar.dev/mstar/goutils/src/branch/main/LICENSE) +[![Go Documentation](https://godocs.io/git.mstar.dev/mstar/goutils?status.svg)](https://godocs.io/git.mstar.dev/mstar/goutils) +[![GoDoc](https://pkg.go.dev/badge/git.mstar.dev/mstar/goutils)](https://pkg.go.dev/git.mstar.dev/mstar/goutils) A collection of useful functions and structs that I use for my projects. diff --git a/containers/generics.go b/containers/generics.go index 9465afd..993cf81 100644 --- a/containers/generics.go +++ b/containers/generics.go @@ -1,3 +1,4 @@ +// Package containers contains implementations of a generic queue and stack package containers type chainElem[T any] struct { diff --git a/embedFsWrapper/wrapper.go b/embedFsWrapper/wrapper.go index e266f91..be6428d 100644 --- a/embedFsWrapper/wrapper.go +++ b/embedFsWrapper/wrapper.go @@ -1,3 +1,5 @@ +// Package embedFsWrapper contains a wrapper around [io/fs.FS] for working +// around a limitation of the [embed] package package embedFsWrapper import ( diff --git a/http/chain.go b/http/chain.go index 30dfb71..23c9328 100644 --- a/http/chain.go +++ b/http/chain.go @@ -1,3 +1,5 @@ +// Package webutils contains various functions for easier interaction and common tasks +// when working with [net/http.Handler] based webservers package webutils import ( diff --git a/maputils/mapUtils.go b/maputils/mapUtils.go index a9db4d5..38bae9d 100644 --- a/maputils/mapUtils.go +++ b/maputils/mapUtils.go @@ -1,3 +1,4 @@ +// Package maputils contains various generic functions for applying an operation on an entire map package maputils import "git.mstar.dev/mstar/goutils/sliceutils" diff --git a/math/math.go b/math/math.go index 8f04ebc..d33fe2f 100644 --- a/math/math.go +++ b/math/math.go @@ -1,3 +1,4 @@ +// Package mathutils contains helper functions for performing common mathematical operations package mathutils type SignedNumber interface { diff --git a/other/other.go b/other/other.go index f63b0b0..c20ec0d 100644 --- a/other/other.go +++ b/other/other.go @@ -1,3 +1,4 @@ +// Package other contains various funtions that didn't fit into any of the other packages package other import "fmt" diff --git a/sliceutils/sliceUtils.go b/sliceutils/sliceUtils.go index 19a9a6f..26e45b9 100644 --- a/sliceutils/sliceUtils.go +++ b/sliceutils/sliceUtils.go @@ -1,3 +1,4 @@ +// Package sliceutils contains various generic functions for applying an operation across an entire slice package sliceutils // MapS applies a given function to every element of a slice. From 506834c88157a2f9737b569f3e8f1693a099098f Mon Sep 17 00:00:00 2001 From: mStar Date: Sun, 4 May 2025 19:28:03 +0200 Subject: [PATCH 17/23] Extend logging middleware with more config options --- http/zerolog.go | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/http/zerolog.go b/http/zerolog.go index 61cec54..249baa3 100644 --- a/http/zerolog.go +++ b/http/zerolog.go @@ -5,19 +5,36 @@ import ( "strings" "time" + "github.com/rs/zerolog" "github.com/rs/zerolog/hlog" "github.com/rs/zerolog/log" ) -func BuildLoggingMiddleware(extras map[string]string) HandlerBuilder { +func BuildLoggingMiddleware( + status500IsError bool, + ignorePaths []string, + extras map[string]string, +) HandlerBuilder { return func(h http.Handler) http.Handler { return ChainMiddlewares(h, hlog.NewHandler(log.Logger), hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) { - if strings.HasPrefix(r.URL.Path, "/assets") { - return + for _, p := range ignorePaths { + if strings.HasPrefix(r.URL.Path, p) { + return + } } - logger := hlog.FromRequest(r).Info(). + var logger *zerolog.Event + if status >= 500 { + if status500IsError { + logger = hlog.FromRequest(r).Error() + } else { + logger = hlog.FromRequest(r).Warn() + } + } else { + logger = hlog.FromRequest(r).Info() + } + logger = logger. Str("method", r.Method). Stringer("url", r.URL). Int("status", status). From d7ed5b1eae6aa3eebae1898221798db14cf6d192 Mon Sep 17 00:00:00 2001 From: mStar Date: Mon, 12 May 2025 15:08:53 +0200 Subject: [PATCH 18/23] fix(webutils): Bad Ip logging Fix the logging middleware only logging the last proxy's ip instead of the ip of the actual request --- http/zerolog.go | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/http/zerolog.go b/http/zerolog.go index 249baa3..026ea9b 100644 --- a/http/zerolog.go +++ b/http/zerolog.go @@ -45,7 +45,7 @@ func BuildLoggingMiddleware( } logger.Send() }), - hlog.RemoteAddrHandler("ip"), + RealIpAppenderMiddleware("ip"), hlog.UserAgentHandler("user_agent"), hlog.RefererHandler("referer"), hlog.RequestIDHandler("req_id", "Request-Id"), @@ -68,9 +68,33 @@ func LoggingMiddleware(handler http.Handler) http.Handler { Dur("duration", duration). Send() }), - hlog.RemoteAddrHandler("ip"), + RealIpAppenderMiddleware("ip"), hlog.UserAgentHandler("user_agent"), hlog.RefererHandler("referer"), hlog.RequestIDHandler("req_id", "Request-Id"), ) } + +// hlog.RemoteAddrHandler except fixed to check the X-Real-Ip and X-Forwarded-For +// headers first for the IP instead of relying on RemoteAddr +// (which would only return the last proxy's address instead of the caller's) +func RealIpAppenderMiddleware(fieldKey string) func(handler http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + IPAddress := r.Header.Get("X-Real-Ip") + if IPAddress == "" { + IPAddress = r.Header.Get("X-Forwarded-For") + } + if IPAddress == "" { + IPAddress = r.RemoteAddr + } + if IPAddress != "" { + log := zerolog.Ctx(r.Context()) + log.UpdateContext(func(c zerolog.Context) zerolog.Context { + return c.Str(fieldKey, r.RemoteAddr) + }) + } + next.ServeHTTP(w, r) + }) + } +} From 4eb5d68fbfa7a51f5983da226f85137c41b81b9c Mon Sep 17 00:00:00 2001 From: mStar Date: Mon, 12 May 2025 15:35:45 +0200 Subject: [PATCH 19/23] Fix for the fix I didn't actually write the found ip to the logging stack (or whatever zerolog uses). This is now fixed --- http/zerolog.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/http/zerolog.go b/http/zerolog.go index 026ea9b..0249d21 100644 --- a/http/zerolog.go +++ b/http/zerolog.go @@ -91,7 +91,7 @@ func RealIpAppenderMiddleware(fieldKey string) func(handler http.Handler) http.H if IPAddress != "" { log := zerolog.Ctx(r.Context()) log.UpdateContext(func(c zerolog.Context) zerolog.Context { - return c.Str(fieldKey, r.RemoteAddr) + return c.Str(fieldKey, IPAddress) }) } next.ServeHTTP(w, r) From 7881a554366b691fe4604d645e186ec081571e73 Mon Sep 17 00:00:00 2001 From: mstar Date: Fri, 6 Jun 2025 17:07:31 +0200 Subject: [PATCH 20/23] Add Server Side Events helper --- http/sse.go | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 http/sse.go diff --git a/http/sse.go b/http/sse.go new file mode 100644 index 0000000..8f72b99 --- /dev/null +++ b/http/sse.go @@ -0,0 +1,37 @@ +package webutils + +import ( + "errors" + "fmt" + "net/http" +) + +var ( + ErrWriterNotFlushable = errors.New("response writer not flushable") +) + +// SseWriter provides a simple implementation for sending data via Server Side Events +// to the client. The function runs until the dataStream channel is closed. +// The ResponseWriter must not be used after calling this function +// +// Inspired by and partially copied from https://medium.com/@rian.eka.cahya/server-sent-event-sse-with-go-10592d9c2aa1 +func SseWriter(w http.ResponseWriter, dataStream chan []byte) error { + w.Header().Set("Access-Control-Expose-Headers", "Content-Type") + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + + flusher, ok := w.(http.Flusher) + if !ok { + return ErrWriterNotFlushable + } + for data := range dataStream { + _, err := fmt.Fprintf(w, "%s\n\n", string(data)) + if err != nil { + return err + } + flusher.Flush() + } + return nil +} From 3bb1984f1cb1f4a2dd620ee16b07c8f499a82b98 Mon Sep 17 00:00:00 2001 From: mstar Date: Tue, 10 Jun 2025 13:33:17 +0200 Subject: [PATCH 21/23] Actually make SseWriter be spec compliant - Change inbound data type from byte slice to dedicated event message type - Include data validation for id and event type - Better documentation --- http/sse.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 46 insertions(+), 5 deletions(-) diff --git a/http/sse.go b/http/sse.go index 8f72b99..76b4f42 100644 --- a/http/sse.go +++ b/http/sse.go @@ -4,18 +4,36 @@ import ( "errors" "fmt" "net/http" + "strings" ) +type SseEvent struct { + Type *string + Id *string + Data string +} + var ( ErrWriterNotFlushable = errors.New("response writer not flushable") + ErrEventTypeNoNewline = errors.New("event type contained a newline") + ErrEventIdNoNewline = errors.New("event id contained a newline") ) // SseWriter provides a simple implementation for sending data via Server Side Events -// to the client. The function runs until the dataStream channel is closed. -// The ResponseWriter must not be used after calling this function +// to the client. The function returns once the dataStream channel is closed. +// The ResponseWriter should not be used after calling this function +// +// Event rules: +// - SseEvent.Type must not contain any newlines +// - SseEvent.Id must not contain any newlines +// - "\r\n" in SseEvent.Data will be replaced with "\n", afterwards "\r" will also be replaced with "\n" // // Inspired by and partially copied from https://medium.com/@rian.eka.cahya/server-sent-event-sse-with-go-10592d9c2aa1 -func SseWriter(w http.ResponseWriter, dataStream chan []byte) error { +// Also why this and not github.com/tmaxmax/go-sse? +// go-sse is not flexible in its use case. You have one handler that doesn't differentiate between clients. +// This, of course, does not work when you have to send individualised notifications as everyone would +// get the same messages, causing confusion in the best case and data leaks in the worst case +func SseWriter(w http.ResponseWriter, dataStream chan SseEvent) error { w.Header().Set("Access-Control-Expose-Headers", "Content-Type") w.Header().Set("Content-Type", "text/event-stream") @@ -27,11 +45,34 @@ func SseWriter(w http.ResponseWriter, dataStream chan []byte) error { return ErrWriterNotFlushable } for data := range dataStream { - _, err := fmt.Fprintf(w, "%s\n\n", string(data)) - if err != nil { + if err := ValidateSseEvent(&data); err != nil { return err } + if data.Type != nil { + _, _ = fmt.Fprintf(w, "event: %s\n", *data.Type) + } + if data.Id != nil { + _, _ = fmt.Fprintf(w, "id: %s\n", *data.Id) + } + noCr := strings.ReplaceAll(data.Data, "\r\n", "\n") + noCr = strings.ReplaceAll(noCr, "\r", "\n") + for _, line := range strings.Split(noCr, "\n") { + _, _ = fmt.Fprintf(w, "data: %s\n", line) + } + _, _ = fmt.Fprint(w, "\n\n") flusher.Flush() } return nil } + +// Check if a given event is valid for sending via SseWriter +// This function is also internally called by SseWriter for each outbound event +func ValidateSseEvent(e *SseEvent) error { + if e.Type != nil && strings.ContainsRune(*e.Type, '\n') { + return ErrEventTypeNoNewline + } + if e.Id != nil && strings.ContainsRune(*e.Id, '\n') { + return ErrEventIdNoNewline + } + return nil +} From 7a09569c039595b5872c03fa24d0c30a3a6b3efb Mon Sep 17 00:00:00 2001 From: mstar Date: Tue, 17 Jun 2025 13:28:25 +0200 Subject: [PATCH 22/23] Docs and sliceutils from and to channel --- maputils/mapUtils.go | 2 ++ sliceutils/sliceUtils.go | 31 ++++++++++++++++++++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/maputils/mapUtils.go b/maputils/mapUtils.go index 38bae9d..3565525 100644 --- a/maputils/mapUtils.go +++ b/maputils/mapUtils.go @@ -73,6 +73,8 @@ func CompareMap[K, V comparable](a, b map[K]V) bool { return true } +// Compact reduces the keys and values of a map down into one value each. +// The starting value for each is the default value of that type. func Compact[K comparable, V any]( m map[K]V, compactor func(accK K, accV V, nextK K, nextV V) (K, V), diff --git a/sliceutils/sliceUtils.go b/sliceutils/sliceUtils.go index 26e45b9..8b427e9 100644 --- a/sliceutils/sliceUtils.go +++ b/sliceutils/sliceUtils.go @@ -1,7 +1,7 @@ // Package sliceutils contains various generic functions for applying an operation across an entire slice package sliceutils -// MapS applies a given function to every element of a slice. +// Map applies a given function to every element of a slice. // The return type may be different from the initial type of the slice. func Map[T any, M any](arr []T, apply func(T) M) []M { n := make([]M, len(arr)) @@ -73,6 +73,7 @@ func CompareUnordered[T comparable](a, b []T) bool { return hits == len(a) } +// Returns whether b exists inside a using a simple == comparison func Contains[T comparable](a []T, b T) bool { for _, v := range a { if v == b { @@ -82,6 +83,7 @@ func Contains[T comparable](a []T, b T) bool { return false } +// Returns whether the given function f returns true for any element in a func ContainsFunc[T any](a []T, f func(t T) bool) bool { for _, v := range a { if f(v) { @@ -91,6 +93,8 @@ func ContainsFunc[T any](a []T, f func(t T) bool) bool { return false } +// Compact the slice a using the compactor function. +// For the first call, the accumulator argument will be the default value func Compact[T any](a []T, compactor func(acc T, next T) T) T { var acc T for _, v := range a { @@ -98,3 +102,28 @@ func Compact[T any](a []T, compactor func(acc T, next T) T) T { } return acc } + +// Returns a channel that all elements in a will be written to in order. +// Once all values of a have been sent, the channel will be closed. +// The channel must be fully consumed until closed. Otherwise a goroutine will be leaked +func ToChannel[T any](a []T) chan T { + c := make(chan T) + go func() { + for _, v := range a { + c <- v + } + close(c) + }() + + return c +} + +// FromChannel reads from a channel until closed, appending every element to a slice. +// If you do not know how many elements to expect, use an expectedSize of 0 +func FromChannel[T any](c chan T, expectedSize uint) []T { + a := make([]T, expectedSize) + for v := range c { + a = append(a, v) + } + return a +} From d303f551f32de76a4c4afd08494c35ed7f4f6b4e Mon Sep 17 00:00:00 2001 From: mstar Date: Tue, 17 Jun 2025 13:37:05 +0200 Subject: [PATCH 23/23] Improve channel type hint --- sliceutils/sliceUtils.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sliceutils/sliceUtils.go b/sliceutils/sliceUtils.go index 8b427e9..309db36 100644 --- a/sliceutils/sliceUtils.go +++ b/sliceutils/sliceUtils.go @@ -106,7 +106,7 @@ func Compact[T any](a []T, compactor func(acc T, next T) T) T { // Returns a channel that all elements in a will be written to in order. // Once all values of a have been sent, the channel will be closed. // The channel must be fully consumed until closed. Otherwise a goroutine will be leaked -func ToChannel[T any](a []T) chan T { +func ToChannel[T any](a []T) <-chan T { c := make(chan T) go func() { for _, v := range a { @@ -120,7 +120,7 @@ func ToChannel[T any](a []T) chan T { // FromChannel reads from a channel until closed, appending every element to a slice. // If you do not know how many elements to expect, use an expectedSize of 0 -func FromChannel[T any](c chan T, expectedSize uint) []T { +func FromChannel[T any](c <-chan T, expectedSize uint) []T { a := make([]T, expectedSize) for v := range c { a = append(a, v)