Compare commits
22 commits
Author | SHA1 | Date | |
---|---|---|---|
9870d87d41 | |||
15887d9d2e | |||
e8aa16622b | |||
4612ee993e | |||
9b6664399f | |||
2bc73d2262 | |||
0a22727d46 | |||
0eafc6806b | |||
afcc54c831 | |||
eedc7b9dc1 | |||
59539c7e97 | |||
18bf623114 | |||
0a0c185c06 | |||
c6860b4a3a | |||
40d31bc53f | |||
77e09295be | |||
f387fcb1b9 | |||
14c58baa95 | |||
8d8fe03598 | |||
7722a73ba2 | |||
ea4a111af6 | |||
9d803a94ee |
14 changed files with 408 additions and 18 deletions
11
go.mod
11
go.mod
|
@ -1,3 +1,12 @@
|
|||
module gitlab.com/mstarongitlab/goutils
|
||||
module git.mstar.dev/mstar/goutils
|
||||
|
||||
go 1.19
|
||||
|
||||
require github.com/rs/zerolog v1.33.0
|
||||
|
||||
require (
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.19 // indirect
|
||||
github.com/rs/xid v1.5.0 // indirect
|
||||
golang.org/x/sys v0.12.0 // indirect
|
||||
)
|
||||
|
|
16
go.sum
Normal file
16
go.sum
Normal file
|
@ -0,0 +1,16 @@
|
|||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
16
http/chain.go
Normal file
16
http/chain.go
Normal file
|
@ -0,0 +1,16 @@
|
|||
package webutils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type HandlerBuilder func(http.Handler) http.Handler
|
||||
|
||||
func ChainMiddlewares(base http.Handler, links ...HandlerBuilder) http.Handler {
|
||||
slices.Reverse(links)
|
||||
for _, f := range links {
|
||||
base = f(base)
|
||||
}
|
||||
return base
|
||||
}
|
19
http/context.go
Normal file
19
http/context.go
Normal file
|
@ -0,0 +1,19 @@
|
|||
package webutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func ContextValsMiddleware(pairs map[any]any) HandlerBuilder {
|
||||
return func(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
for key, val := range pairs {
|
||||
ctx = context.WithValue(ctx, key, val)
|
||||
}
|
||||
newRequest := r.WithContext(ctx)
|
||||
h.ServeHTTP(w, newRequest)
|
||||
})
|
||||
}
|
||||
}
|
72
http/httpErr.go
Normal file
72
http/httpErr.go
Normal 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
17
http/json.go
Normal 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
59
http/zerolog.go
Normal 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
67
logrotate/logrotate.go
Normal 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
|
||||
}
|
|
@ -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
13
math/math.go
Normal 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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
76
other/zerolog.go
Normal 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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue