Compare commits

...

20 commits
v1.5.1 ... main

Author SHA1 Message Date
9870d87d41
Add sliceutils.ContainsFunc 2025-04-11 12:38:41 +02:00
15887d9d2e
Add builder for Logging middleware
Builder offers option to add extra fields to log messages
2025-04-11 10:44:50 +02:00
e8aa16622b
Add generic math abs 2025-04-10 14:36:51 +02:00
4612ee993e
BREAKING CHANGE: Rename http module to webutils 2025-04-09 13:57:01 +02:00
9b6664399f
Fix header write order
Headers need to be written before status code
2025-04-09 13:11:12 +02:00
2bc73d2262
Add ProblemDetails support and deprecate HttpErr 2025-04-09 09:51:36 +02:00
0a22727d46
BREAKING CHANGE: Update ConfigureLogging
Now takes a writer as additional output next to stderr
to write logs to
2025-04-08 17:09:50 +02:00
0eafc6806b
fix(logrotate): Now creates dir needed
If the directory containing the target log file doesn't exist, try and
create the directory first
2025-03-24 17:28:38 +01:00
afcc54c831
BREAKING CHANGE: Remodelled logging setup
- other.ConfigureLoggingFromCliArgs has been renamed to
  other.ConfigureLogging
- The -logfile cli argument has been removed and replaced with an
  argument to other.ConfigureLogging. This argument takes a pointer to a
  string or nil. If it's a valid pointer, it's used just like the
  -logfile cli flag before
- The logfile, if requested via the previously mentioned parameter, can
  now be rotated by calling the returned function. If no logfile is
  specified, the returned function does nothing
2025-03-24 08:39:04 +01:00
eedc7b9dc1
Add log rotator 2025-03-24 08:23:36 +01:00
59539c7e97
Add error formatter and docs 2025-03-04 13:57:51 +01:00
18bf623114
Restructure all http related files
Moving all http related functions and helpers into a new http submodule
2025-03-04 13:56:57 +01:00
0a0c185c06 Swap order of logging setup
Fixes unformatted logging output at beginning
2025-02-03 14:26:56 +01:00
c6860b4a3a Fix wrong var name 2024-12-30 14:44:11 +01:00
40d31bc53f Add zerolog boilerplate helpers
Includes 3 cli vars ("loglevel", "logjson", "logfile")
One public function to configure the global zerolog handler based on
those arguments
2024-12-30 14:25:25 +01:00
77e09295be Fix maputils 2024-12-02 08:42:42 +01:00
f387fcb1b9 Fix module name 2024-12-01 10:41:26 +01:00
14c58baa95 Merge branch 'main' of gitlab.com:mstarongitlab/goutils 2024-12-01 10:32:26 +01:00
8d8fe03598 Add map compactor 2024-12-01 10:31:46 +01:00
7722a73ba2 Add compact slice function 2024-12-01 10:29:28 +01:00
14 changed files with 350 additions and 52 deletions

2
go.mod
View file

@ -1,4 +1,4 @@
module gitlab.com/mstarongitlab/goutils
module git.mstar.dev/mstar/goutils
go 1.19

View file

@ -1,4 +1,4 @@
package middleware
package webutils
import (
"net/http"

View file

@ -1,4 +1,4 @@
package middleware
package webutils
import (
"context"

72
http/httpErr.go Normal file
View file

@ -0,0 +1,72 @@
package webutils
import (
"encoding/json"
"fmt"
"net/http"
)
// Return an error over an http connection.
// The error will have the given return code `code`
// and a json encoded body with the field "id" set to `errId`
// 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) {
w.WriteHeader(code)
w.Header().Add("Content-Type", "application/json")
fmt.Fprintf(w, "{\"id\": %d, \"message\": \"%s\"}", errId, message)
}
// Write an RFC 9457 compliant problem details response
// If details is not nil, it will be included.
// If extras is not nil, each key-value pair will be included at
// the root layer.
// Keys in extras that would overwrite the default elements will be ignored.
// Those would be "type", "status", "title" and "detail"
func ProblemDetails(
w http.ResponseWriter,
statusCode int,
errorType string,
errorTitle string,
details *string,
extras map[string]any,
) {
w.Header().Add("Content-Type", "application/problem+json")
w.WriteHeader(statusCode)
data := map[string]any{
"type": errorType,
"status": statusCode,
"title": errorTitle,
}
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
}
}
enc := json.NewEncoder(w)
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) {
w.Header().Add("Content-Type", "application/problem+json")
w.WriteHeader(statusCode)
data := map[string]any{
"type": "about:blank",
"title": http.StatusText(statusCode),
"status": statusCode,
"reference": "RFC 9457",
}
enc := json.NewEncoder(w)
enc.Encode(data)
}

17
http/json.go Normal file
View file

@ -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
}

59
http/zerolog.go Normal file
View file

@ -0,0 +1,59 @@
package webutils
import (
"net/http"
"strings"
"time"
"github.com/rs/zerolog/hlog"
"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),
hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
if strings.HasPrefix(r.URL.Path, "/assets") {
return
}
hlog.FromRequest(r).Info().
Str("method", r.Method).
Stringer("url", r.URL).
Int("status", status).
Int("size", size).
Dur("duration", duration).
Send()
}),
hlog.RemoteAddrHandler("ip"),
hlog.UserAgentHandler("user_agent"),
hlog.RefererHandler("referer"),
hlog.RequestIDHandler("req_id", "Request-Id"),
)
}

67
logrotate/logrotate.go Normal file
View file

@ -0,0 +1,67 @@
// 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
}

View file

@ -1,6 +1,6 @@
package maputils
import "gitlab.com/mstarongitlab/goutils/sliceutils"
import "git.mstar.dev/mstar/goutils/sliceutils"
// MapSameKeys applies a given function to every key-value pair of a map.
// The returned map's value type may be different from the type of the inital map's value
@ -71,3 +71,15 @@ func CompareMap[K, V comparable](a, b map[K]V) bool {
}
return true
}
func Compact[K comparable, V any](
m map[K]V,
compactor func(accK K, accV V, nextK K, nextV V) (K, V),
) (K, V) {
var accK K
var accV V
for k, v := range m {
accK, accV = compactor(accK, accV, k, v)
}
return accK, accV
}

13
math/math.go Normal file
View file

@ -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
}
}

View file

@ -1,32 +0,0 @@
package middleware
import (
"net/http"
"strings"
"time"
"github.com/rs/zerolog/hlog"
"github.com/rs/zerolog/log"
)
func LoggingMiddleware(handler http.Handler) http.Handler {
return ChainMiddlewares(handler,
hlog.NewHandler(log.Logger),
hlog.AccessHandler(func(r *http.Request, status, size int, duration time.Duration) {
if strings.HasPrefix(r.URL.Path, "/assets") {
return
}
hlog.FromRequest(r).Info().
Str("method", r.Method).
Stringer("url", r.URL).
Int("status", status).
Int("size", size).
Dur("duration", duration).
Send()
}),
hlog.RemoteAddrHandler("ip"),
hlog.UserAgentHandler("user_agent"),
hlog.RefererHandler("referer"),
hlog.RequestIDHandler("req_id", "Request-Id"),
)
}

View file

@ -1,16 +0,0 @@
package other
import (
"fmt"
"net/http"
)
// Return an error over an http connection.
// The error will have the given return code `code`
// and a json encoded body with the field "id" set to `errId`
// and a field "message" set to the `message`
func HttpErr(w http.ResponseWriter, errId int, message string, code int) {
w.WriteHeader(code)
w.Header().Add("Content-Type", "application/json")
fmt.Fprintf(w, "{\"id\": %d, \"message\": \"%s\"}", errId, message)
}

View file

@ -1,5 +1,7 @@
package other
import "fmt"
// OutputIntoChannel takes a singular return value and sends it into the target channel.
// This is a wrapper for functions where a value can't be collected from directly.
// Example: goroutines
@ -7,6 +9,10 @@ func OutputIntoChannel[T any](out T, target chan T) {
target <- out
}
// Must is a quick wrapper to ensure that a function exits without error.
// If err is not nil, it panics. Otherwise val is returned.
// The intended use is something like Must(someFunc()), where someFunc
// has the signature someFunc() (T, error), with T being whatever type needed
func Must[T any](val T, err error) T {
if err != nil {
panic(err)
@ -14,6 +20,13 @@ func Must[T any](val T, err error) T {
return val
}
// IntoPointer returns a pointer to the given value
func IntoPointer[T any](val T) *T {
return &val
}
// Error formats error messages to follow a common convention of
// "source: message: wrapped error"
func Error(source, message string, err error) error {
return fmt.Errorf("%s: %s: %w", source, message, err)
}

76
other/zerolog.go Normal file
View file

@ -0,0 +1,76 @@
package other
import (
"flag"
"io"
"os"
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
var cliFlagLogLevel = "info"
var cliFlagLogJson = false
func SetupFlags() {
flag.StringVar(
&cliFlagLogLevel,
"loglevel",
"info",
"Set the logging level. Valid values (case insensitive) are: debug, info, warn, error, fatal",
)
flag.BoolVar(
&cliFlagLogJson,
"logjson",
false,
"Log json objects to stderr instead of nicely formatted ones",
)
}
// Configure logging. Utilises the flags setup in [SetupFlags].
// If logWriter is not nil, will also write logs, as json objects,
// to the given writer
func ConfigureLogging(logWriter io.Writer) {
configOutputs(logWriter)
configLevel()
}
func configLevel() {
switch strings.ToLower(cliFlagLogLevel) {
case "trace":
zerolog.SetGlobalLevel(zerolog.TraceLevel)
case "debug":
zerolog.SetGlobalLevel(zerolog.DebugLevel)
case "info":
zerolog.SetGlobalLevel(zerolog.InfoLevel)
case "warn":
zerolog.SetGlobalLevel(zerolog.WarnLevel)
case "error":
zerolog.SetGlobalLevel(zerolog.ErrorLevel)
case "fatal":
zerolog.SetGlobalLevel(zerolog.FatalLevel)
default:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
}
log.Info().Str("new-level", cliFlagLogLevel).Msg("New logging level set")
}
func configOutputs(logWriter io.Writer) {
console := zerolog.ConsoleWriter{Out: os.Stderr}
extraLogWriters := []io.Writer{console}
if logWriter != nil {
extraLogWriters = append(extraLogWriters, logWriter)
}
if !cliFlagLogJson {
log.Logger = zerolog.New(zerolog.MultiLevelWriter(extraLogWriters...)).
With().
Timestamp().
Logger()
} else {
log.Logger = zerolog.New(zerolog.MultiLevelWriter(
append([]io.Writer{log.Logger}, extraLogWriters...)...,
)).With().Timestamp().Logger()
}
return
}

View file

@ -80,3 +80,20 @@ 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 {
acc = compactor(acc, v)
}
return acc
}