(Feat): Initial Commit
This commit is contained in:
11
internal/util/bool.go
Normal file
11
internal/util/bool.go
Normal file
@@ -0,0 +1,11 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
// FalseIfNil returns false if the passed pointer is nil. Passing a pointer to a bool will return the value of the bool.
|
||||
func FalseIfNil(b *bool) bool {
|
||||
if b == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *b
|
||||
}
|
||||
16
internal/util/bool_test.go
Normal file
16
internal/util/bool_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFalseIfNil(t *testing.T) {
|
||||
b := true
|
||||
assert.True(t, util.FalseIfNil(&b))
|
||||
b = false
|
||||
assert.False(t, util.FalseIfNil(&b))
|
||||
assert.False(t, util.FalseIfNil(nil))
|
||||
}
|
||||
69
internal/util/cache_control.go
Normal file
69
internal/util/cache_control.go
Normal file
@@ -0,0 +1,69 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CacheControlDirective uint8
|
||||
|
||||
const (
|
||||
CacheControlDirectiveNoCache CacheControlDirective = 1 << iota
|
||||
CacheControlDirectiveNoStore
|
||||
)
|
||||
|
||||
func (d *CacheControlDirective) HasDirective(dir CacheControlDirective) bool { return *d&dir != 0 }
|
||||
func (d *CacheControlDirective) AddDirective(dir CacheControlDirective) { *d |= dir }
|
||||
func (d *CacheControlDirective) ClearDirective(dir CacheControlDirective) { *d &= ^dir }
|
||||
func (d *CacheControlDirective) ToggleDirective(dir CacheControlDirective) { *d ^= dir }
|
||||
|
||||
func (d *CacheControlDirective) String() string {
|
||||
res := make([]string, 0)
|
||||
|
||||
if d.HasDirective(CacheControlDirectiveNoCache) {
|
||||
res = append(res, "no-cache")
|
||||
}
|
||||
if d.HasDirective(CacheControlDirectiveNoStore) {
|
||||
res = append(res, "no-store")
|
||||
}
|
||||
|
||||
return strings.Join(res, "|")
|
||||
}
|
||||
|
||||
func ParseCacheControlDirective(d string) CacheControlDirective {
|
||||
parts := strings.Split(d, "=")
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "no-cache":
|
||||
return CacheControlDirectiveNoCache
|
||||
case "no-store":
|
||||
return CacheControlDirectiveNoStore
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func ParseCacheControlHeader(val string) CacheControlDirective {
|
||||
res := CacheControlDirective(0)
|
||||
|
||||
directives := strings.Split(val, ",")
|
||||
for _, dir := range directives {
|
||||
res |= ParseCacheControlDirective(dir)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func CacheControlDirectiveFromContext(ctx context.Context) CacheControlDirective {
|
||||
d := ctx.Value(CTXKeyCacheControl)
|
||||
if d == nil {
|
||||
return CacheControlDirective(0)
|
||||
}
|
||||
|
||||
directive, ok := d.(CacheControlDirective)
|
||||
if !ok {
|
||||
return CacheControlDirective(0)
|
||||
}
|
||||
|
||||
return directive
|
||||
}
|
||||
77
internal/util/cache_control_test.go
Normal file
77
internal/util/cache_control_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/middleware"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCacheControl(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
path := "/testing-1c2cad5f-7545-4177-9bfe-8dc7ed368b33"
|
||||
|
||||
s.Echo.GET(path, func(c echo.Context) error {
|
||||
cache := util.CacheControlDirectiveFromContext(c.Request().Context())
|
||||
|
||||
switch {
|
||||
case cache.HasDirective(util.CacheControlDirectiveNoCache) && cache.HasDirective(util.CacheControlDirectiveNoStore):
|
||||
return c.JSON(http.StatusOK, "no-cache,no-store")
|
||||
case cache.HasDirective(util.CacheControlDirectiveNoCache):
|
||||
return c.JSON(http.StatusOK, "no-cache")
|
||||
case cache.HasDirective(util.CacheControlDirectiveNoStore):
|
||||
return c.JSON(http.StatusOK, "no-store")
|
||||
}
|
||||
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}, middleware.CacheControl())
|
||||
|
||||
cacheControlNoCache := util.CacheControlDirectiveNoCache
|
||||
cacheControlNoStore := util.CacheControlDirectiveNoStore
|
||||
|
||||
header := http.Header{}
|
||||
header.Set(util.HTTPHeaderCacheControl, fmt.Sprintf("%s,%s", cacheControlNoStore.String(), cacheControlNoCache.String()))
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", path, nil, header)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var resp string
|
||||
err := json.NewDecoder(res.Result().Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "no-cache,no-store", resp)
|
||||
|
||||
header.Set(util.HTTPHeaderCacheControl, cacheControlNoCache.String())
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", path, nil, header)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
err = json.NewDecoder(res.Result().Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "no-cache", resp)
|
||||
|
||||
header.Set(util.HTTPHeaderCacheControl, cacheControlNoStore.String())
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", path, nil, header)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
err = json.NewDecoder(res.Result().Body).Decode(&resp)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "no-store", resp)
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", path, nil, nil)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
|
||||
header.Set(util.HTTPHeaderCacheControl, "gunther")
|
||||
|
||||
res = test.PerformRequest(t, s, "GET", path, nil, header)
|
||||
assert.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
})
|
||||
}
|
||||
76
internal/util/command/command.go
Normal file
76
internal/util/command/command.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package command
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
LogKeyCmdExecutionID = "cmdExecutionId"
|
||||
shutdownTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
func WithServer(ctx context.Context, config config.Server, handler func(ctx context.Context, s *api.Server) error) error {
|
||||
ctx = log.With().Str(LogKeyCmdExecutionID, uuid.New().String()).Logger().WithContext(ctx)
|
||||
|
||||
zerolog.TimeFieldFormat = time.RFC3339Nano
|
||||
zerolog.SetGlobalLevel(config.Logger.Level)
|
||||
if config.Logger.PrettyPrintConsole {
|
||||
log.Logger = log.Output(zerolog.NewConsoleWriter(func(w *zerolog.ConsoleWriter) {
|
||||
w.TimeFormat = "15:04:05"
|
||||
}))
|
||||
}
|
||||
|
||||
s, err := api.InitNewServer(config)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("Failed to initialize server")
|
||||
}
|
||||
|
||||
start := s.Clock.Now()
|
||||
|
||||
err = handler(ctx, s)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
log.Info().Dur("duration", elapsed).Msg("Command execution finished")
|
||||
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Command failed")
|
||||
return err
|
||||
}
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if errs := s.Shutdown(shutdownCtx); len(errs) > 0 {
|
||||
log.Error().Errs("shutdownErrors", errs).Msg("Failed to gracefully shut down server")
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewSubcommandGroup(subcommand string, subcommands ...*cobra.Command) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: fmt.Sprintf("%s <subcommand>", subcommand),
|
||||
Short: fmt.Sprintf("%s related subcommands", subcommand),
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if err := cmd.Help(); err != nil {
|
||||
return fmt.Errorf("failed to print help: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(subcommands...)
|
||||
|
||||
return cmd
|
||||
}
|
||||
34
internal/util/command/command_test.go
Normal file
34
internal/util/command/command_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package command_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/command"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithServer(t *testing.T) {
|
||||
test.WithTestServer(t, func(s *api.Server) {
|
||||
ctx := t.Context()
|
||||
|
||||
var testError = errors.New("test error")
|
||||
|
||||
s.Config.Logger.PrettyPrintConsole = false
|
||||
resultErr := command.WithServer(ctx, s.Config, func(ctx context.Context, s *api.Server) error {
|
||||
var database string
|
||||
err := s.DB.QueryRowContext(ctx, "SELECT current_database();").Scan(&database)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.NotEmpty(t, database)
|
||||
|
||||
return testError
|
||||
})
|
||||
|
||||
assert.Equal(t, testError, resultErr)
|
||||
})
|
||||
}
|
||||
77
internal/util/context.go
Normal file
77
internal/util/context.go
Normal file
@@ -0,0 +1,77 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
CTXKeyUser contextKey = "user"
|
||||
CTXKeyAccessToken contextKey = "access_token"
|
||||
CTXKeyCacheControl contextKey = "cache_control"
|
||||
CTXKeyRequestID contextKey = "request_id"
|
||||
CTXKeyDisableLogger contextKey = "disable_logger"
|
||||
)
|
||||
|
||||
//nolint:containedctx
|
||||
type detachedContext struct {
|
||||
parent context.Context
|
||||
}
|
||||
|
||||
func (c detachedContext) Deadline() (time.Time, bool) { return time.Time{}, false }
|
||||
func (c detachedContext) Done() <-chan struct{} { return nil }
|
||||
func (c detachedContext) Err() error { return nil }
|
||||
func (c detachedContext) Value(key interface{}) interface{} { return c.parent.Value(key) }
|
||||
|
||||
// DetachContext detaches a context by returning a wrapped struct implementing the context interface, but omitting the deadline, done and error functionality.
|
||||
// Mainly used to pass context information to go routines that should not be cancelled by the context.
|
||||
// ! USE THIS DETACHED CONTEXT SPARINGLY, ONLY IF ABSOLUTELY NEEDED. DO *NOT* KEEP USING A DETACHED CONTEXT FOR A PROLONGED TIME OUT OF CHAIN
|
||||
func DetachContext(ctx context.Context) context.Context {
|
||||
return detachedContext{ctx}
|
||||
}
|
||||
|
||||
// RequestIDFromContext returns the ID of the (HTTP) request, returning an error if it is not present.
|
||||
func RequestIDFromContext(ctx context.Context) (string, error) {
|
||||
val := ctx.Value(CTXKeyRequestID)
|
||||
if val == nil {
|
||||
return "", errors.New("no request id present in context")
|
||||
}
|
||||
|
||||
id, ok := val.(string)
|
||||
if !ok {
|
||||
return "", errors.New("request id in context is not a string")
|
||||
}
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// ShouldDisableLogger checks whether the logger instance should be disabled for the provided context.
|
||||
// `util.LogFromContext` will use this function to check whether it should return a default logger if
|
||||
// none has been set by our logging middleware before, or fall back to the disabled logger, suppressing
|
||||
// all output. Use `ctx = util.DisableLogger(ctx, true)` to disable logging for the given context.
|
||||
func ShouldDisableLogger(ctx context.Context) bool {
|
||||
s := ctx.Value(CTXKeyDisableLogger)
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
shouldDisable, ok := s.(bool)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return shouldDisable
|
||||
}
|
||||
|
||||
// DisableLogger toggles the indication whether `util.LogFromContext` should return a disabled logger
|
||||
// for a context if none has been set by our logging middleware before. Whilst the usecase for a disabled
|
||||
// logger are relatively minimal (we almost always want to have some log output, even if the context
|
||||
// was not directly derived from a HTTP request), this functionality was provideds so you can switch back
|
||||
// to the old zerolog behavior if so desired.
|
||||
func DisableLogger(ctx context.Context, shouldDisable bool) context.Context {
|
||||
return context.WithValue(ctx, CTXKeyDisableLogger, shouldDisable)
|
||||
}
|
||||
86
internal/util/context_test.go
Normal file
86
internal/util/context_test.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
func TestDetachContextWithCancel(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
|
||||
var key contextKey = "test"
|
||||
val := 42
|
||||
ctx2 := context.WithValue(ctx, key, val)
|
||||
detachedContext := util.DetachContext(ctx2)
|
||||
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Log("Context cancelled")
|
||||
default:
|
||||
t.Error("Context is not canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx2.Done():
|
||||
t.Log("Context with value cancelled")
|
||||
default:
|
||||
t.Error("Context with value is not canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-detachedContext.Done():
|
||||
t.Error("Detached context is cancelled")
|
||||
default:
|
||||
t.Log("Detached context is not cancelled")
|
||||
}
|
||||
|
||||
res, ok := detachedContext.Value(key).(int)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, val, res)
|
||||
}
|
||||
|
||||
func TestDetachContextWithDeadline(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Second*1)
|
||||
defer cancel()
|
||||
|
||||
var key contextKey = "test"
|
||||
val := 42
|
||||
ctx2 := context.WithValue(ctx, key, val)
|
||||
detachedContext := util.DetachContext(ctx2)
|
||||
|
||||
time.Sleep(time.Second * 2)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
t.Log("Context cancelled")
|
||||
default:
|
||||
t.Error("Context is not canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx2.Done():
|
||||
t.Log("Context with value cancelled")
|
||||
default:
|
||||
t.Error("Context with value is not canceled")
|
||||
}
|
||||
|
||||
select {
|
||||
case <-detachedContext.Done():
|
||||
t.Error("Detached context is cancelled")
|
||||
default:
|
||||
t.Log("Detached context is not cancelled")
|
||||
}
|
||||
|
||||
res, ok := detachedContext.Value(key).(int)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, val, res)
|
||||
}
|
||||
60
internal/util/currency.go
Normal file
60
internal/util/currency.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import "github.com/go-openapi/swag"
|
||||
|
||||
const (
|
||||
centFactor = 100
|
||||
)
|
||||
|
||||
func Int64PtrWithCentsToFloat64Ptr(c *int64) *float64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Int64WithCentsToFloat64Ptr(*c)
|
||||
}
|
||||
|
||||
func Int64WithCentsToFloat64Ptr(c int64) *float64 {
|
||||
return swag.Float64(float64(c) / centFactor)
|
||||
}
|
||||
|
||||
func IntPtrWithCentsToFloat64Ptr(c *int) *float64 {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return IntWithCentsToFloat64Ptr(*c)
|
||||
}
|
||||
|
||||
func IntWithCentsToFloat64Ptr(c int) *float64 {
|
||||
return swag.Float64(float64(c) / centFactor)
|
||||
}
|
||||
|
||||
func Float64PtrToInt64PtrWithCents(f *float64) *int64 {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Int64(Float64PtrToInt64WithCents(f))
|
||||
}
|
||||
|
||||
func Float64PtrToInt64WithCents(f *float64) int64 {
|
||||
return int64(swag.Float64Value(f) * centFactor)
|
||||
}
|
||||
|
||||
func Float64ToInt64WithCents(f float64) int64 {
|
||||
return int64(f * centFactor)
|
||||
}
|
||||
|
||||
func Float64PtrToIntPtrWithCents(f *float64) *int {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Int(Float64PtrToIntWithCents(f))
|
||||
}
|
||||
|
||||
func Float64PtrToIntWithCents(f *float64) int {
|
||||
return int(swag.Float64Value(f) * centFactor)
|
||||
}
|
||||
66
internal/util/currency_test.go
Normal file
66
internal/util/currency_test.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCurrencyConversion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
val int
|
||||
}{
|
||||
{
|
||||
name: "1",
|
||||
val: 999999999999999, // this is the max size with accurate precision
|
||||
},
|
||||
{
|
||||
name: "2",
|
||||
val: 0,
|
||||
},
|
||||
{
|
||||
name: "3",
|
||||
val: -999999999999999,
|
||||
},
|
||||
{
|
||||
name: "4",
|
||||
val: 3333333333333333,
|
||||
},
|
||||
{
|
||||
name: "5",
|
||||
val: 1111111111111111,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
in := int64(tt.val)
|
||||
res := util.Int64PtrWithCentsToFloat64Ptr(&in)
|
||||
out := util.Float64PtrToInt64PtrWithCents(res)
|
||||
assert.Equal(t, in, *out)
|
||||
|
||||
inInt := int(in)
|
||||
res = util.IntPtrWithCentsToFloat64Ptr(&inInt)
|
||||
outInt := util.Float64PtrToIntPtrWithCents(res)
|
||||
assert.Equal(t, inInt, *outInt)
|
||||
outInt2 := util.Float64ToInt64WithCents(*res)
|
||||
assert.Equal(t, int64(inInt), outInt2)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyConversionNil(t *testing.T) {
|
||||
res := util.Int64PtrWithCentsToFloat64Ptr(nil)
|
||||
assert.Nil(t, res)
|
||||
|
||||
res = util.IntPtrWithCentsToFloat64Ptr(nil)
|
||||
assert.Nil(t, res)
|
||||
|
||||
res2 := util.Float64PtrToInt64PtrWithCents(nil)
|
||||
assert.Nil(t, res2)
|
||||
|
||||
res3 := util.Float64PtrToIntPtrWithCents(nil)
|
||||
assert.Nil(t, res3)
|
||||
}
|
||||
109
internal/util/db/db.go
Normal file
109
internal/util/db/db.go
Normal file
@@ -0,0 +1,109 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
)
|
||||
|
||||
type TxFn func(boil.ContextExecutor) error
|
||||
|
||||
func WithTransaction(ctx context.Context, db *sql.DB, txHandler TxFn) error {
|
||||
return WithConfiguredTransaction(ctx, db, nil, txHandler)
|
||||
}
|
||||
|
||||
func WithConfiguredTransaction(ctx context.Context, db *sql.DB, options *sql.TxOptions, txHandler TxFn) error {
|
||||
tx, err := db.BeginTx(ctx, options)
|
||||
if err != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to start transaction")
|
||||
return fmt.Errorf("failed to start transaction: %w", err)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
cause := recover()
|
||||
|
||||
switch {
|
||||
case cause != nil:
|
||||
util.LogFromContext(ctx).Error().Interface("cause", cause).Msg("Recovered from panic, rolling back transaction and panicking again")
|
||||
|
||||
if txErr := tx.Rollback(); txErr != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after recovering from panic")
|
||||
}
|
||||
|
||||
panic(cause)
|
||||
case err != nil:
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Received error, rolling back transaction")
|
||||
|
||||
if txErr := tx.Rollback(); txErr != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(txErr).Msg("Failed to roll back transaction after receiving error")
|
||||
}
|
||||
default:
|
||||
err = tx.Commit()
|
||||
if err != nil {
|
||||
util.LogFromContext(ctx).Warn().Err(err).Msg("Failed to commit transaction")
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = txHandler(tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to execute transaction: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func NullIntFromInt64Ptr(i *int64) null.Int {
|
||||
if i == nil {
|
||||
return null.NewInt(0, false)
|
||||
}
|
||||
|
||||
return null.NewInt(int(*i), true)
|
||||
}
|
||||
|
||||
func NullFloat32FromFloat64Ptr(f *float64) null.Float32 {
|
||||
if f == nil {
|
||||
return null.NewFloat32(0.0, false)
|
||||
}
|
||||
|
||||
return null.NewFloat32(float32(*f), true)
|
||||
}
|
||||
|
||||
func NullIntFromInt16Ptr(i *int16) null.Int {
|
||||
if i == nil {
|
||||
return null.NewInt(0, false)
|
||||
}
|
||||
|
||||
return null.NewInt(int(*i), true)
|
||||
}
|
||||
|
||||
func Int16PtrFromNullInt(i null.Int) *int16 {
|
||||
if !i.Valid || i.Int > math.MaxInt16 || i.Int < math.MinInt16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := int16(i.Int)
|
||||
return &res
|
||||
}
|
||||
|
||||
func Int16PtrFromInt(i int) *int16 {
|
||||
if i > math.MaxInt16 || i < math.MinInt16 {
|
||||
return nil
|
||||
}
|
||||
|
||||
res := int16(i)
|
||||
return &res
|
||||
}
|
||||
|
||||
func NullStringIfEmpty(s string) null.String {
|
||||
if len(s) == 0 {
|
||||
return null.String{}
|
||||
}
|
||||
|
||||
return null.StringFrom(s)
|
||||
}
|
||||
190
internal/util/db/db_test.go
Normal file
190
internal/util/db/db_test.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/aarondl/sqlboiler/v4/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWithTransactionSuccess(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
if err := newUser.Insert(ctx, tx, boil.Infer()); err != nil {
|
||||
return fmt.Errorf("failed to insert user: %w", err)
|
||||
}
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete all users: %w", err)
|
||||
}
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTransactionWithError(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
err = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err := newUser.Insert(ctx, tx, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
newUser2 := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
}
|
||||
|
||||
return newUser2.Insert(ctx, tx, boil.Infer())
|
||||
})
|
||||
require.Error(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count, newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWithTransactionWithPanic(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
|
||||
count, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Positive(t, count)
|
||||
|
||||
panicFunc := func() {
|
||||
_ = db.WithTransaction(ctx, sqlDB, func(tx boil.ContextExecutor) error {
|
||||
newUser := models.User{
|
||||
IsActive: true,
|
||||
Username: null.StringFrom("test"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err := newUser.Insert(ctx, tx, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count+1, newCount)
|
||||
|
||||
delCnt, err := models.Users().DeleteAll(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, newCount, delCnt)
|
||||
|
||||
newCount, err = models.Users().Count(ctx, tx)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(0), newCount)
|
||||
|
||||
panic("some panic")
|
||||
})
|
||||
}
|
||||
|
||||
require.Panics(t, panicFunc)
|
||||
|
||||
newCount, err := models.Users().Count(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, count, newCount)
|
||||
})
|
||||
}
|
||||
|
||||
func TestDBTypeConversions(t *testing.T) {
|
||||
i64 := int64(19)
|
||||
res := db.NullIntFromInt64Ptr(&i64)
|
||||
assert.Equal(t, 19, res.Int)
|
||||
assert.True(t, res.Valid)
|
||||
|
||||
res = db.NullIntFromInt64Ptr(nil)
|
||||
assert.False(t, res.Valid)
|
||||
|
||||
f := 19.9999
|
||||
res2 := db.NullFloat32FromFloat64Ptr(&f)
|
||||
assert.InDelta(t, float32(f), res2.Float32, 0.0001)
|
||||
assert.True(t, res2.Valid)
|
||||
|
||||
res2 = db.NullFloat32FromFloat64Ptr(nil)
|
||||
assert.False(t, res2.Valid)
|
||||
|
||||
i16 := int16(19)
|
||||
res3 := db.NullIntFromInt16Ptr(&i16)
|
||||
assert.Equal(t, 19, res3.Int)
|
||||
assert.True(t, res3.Valid)
|
||||
|
||||
res4 := db.Int16PtrFromNullInt(res3)
|
||||
require.NotEmpty(t, res4)
|
||||
assert.Equal(t, i16, *res4)
|
||||
|
||||
res5 := db.Int16PtrFromNullInt(null.IntFromPtr(nil))
|
||||
assert.Empty(t, res5)
|
||||
|
||||
i := 7
|
||||
res6 := db.Int16PtrFromInt(i)
|
||||
require.NotEmpty(t, res6)
|
||||
assert.Equal(t, i, int(*res6))
|
||||
|
||||
res7 := db.NullStringIfEmpty("")
|
||||
assert.False(t, res7.Valid)
|
||||
|
||||
s := "foo"
|
||||
res8 := db.NullStringIfEmpty(s)
|
||||
assert.True(t, res8.Valid)
|
||||
assert.Equal(t, s, res8.String)
|
||||
}
|
||||
69
internal/util/db/example_test.go
Normal file
69
internal/util/db/example_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
type PublicName struct {
|
||||
First string `json:"firstName"`
|
||||
}
|
||||
|
||||
type Name struct {
|
||||
PublicName
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
}
|
||||
|
||||
type UserFilter struct {
|
||||
Name
|
||||
Country string `json:"country"`
|
||||
City string
|
||||
Scopes []string `json:"scopes"`
|
||||
Age *int `json:"age"`
|
||||
Height *float32 `json:"height"`
|
||||
}
|
||||
|
||||
func ExampleWhereJSON() {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
fmt.Println(sql)
|
||||
fmt.Print("[")
|
||||
for i := range args {
|
||||
if i < len(args)-1 {
|
||||
fmt.Printf("%v, ", args[i])
|
||||
} else {
|
||||
fmt.Printf("%v", args[i])
|
||||
}
|
||||
}
|
||||
fmt.Println("]")
|
||||
|
||||
// Output:
|
||||
// SELECT * FROM "users" WHERE (users.profile->>'firstName' = $1 AND users.profile->>'lastName' = $2 AND users.profile->>'country' = $3 AND users.profile->'scopes' <@ to_jsonb($4::text[]) AND users.profile->>'age' = $5);
|
||||
// [Max, Muster, Austria, &[app user_info], 42]
|
||||
}
|
||||
47
internal/util/db/ilike.go
Normal file
47
internal/util/db/ilike.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
var (
|
||||
likeQueryEscapeRegex = regexp.MustCompile(`(%|_)`)
|
||||
likeQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// ILike returns a query mod containing a pre-formatted ILIKE clause.
|
||||
// The value provided is applied directly - to perform a wildcard search,
|
||||
// enclose the desired search value in `%` as desired before passing it
|
||||
// to ILike.
|
||||
// The path provided will be joined to construct the full SQL path used,
|
||||
// allowing for filtering of values nested across multiple joins if needed.
|
||||
func ILike(val string, path ...string) qm.QueryMod {
|
||||
// ! Attention: we **must** use ? instead of $1 or similar to bind query parameters here since
|
||||
// ! other parts of the query might have already defined $1, leading to incorrect parameters
|
||||
// ! being inserted. On the contrary to other parts using PG queries, ? actually works with qm.Where.
|
||||
return qm.Where(fmt.Sprintf("%s ILIKE ?", strings.Join(path, ".")), val)
|
||||
}
|
||||
|
||||
// ILikeSearch returns a query mod with one or multiple ILIKE clauses in an
|
||||
// AND expression.
|
||||
// The query is split on whitespace characters and for each word an escaped
|
||||
// ILIKE with prefix and suffix wildcard will be generated.
|
||||
func ILikeSearch(query string, path ...string) qm.QueryMod {
|
||||
res := []qm.QueryMod{}
|
||||
|
||||
terms := likeQueryWhiteSpaceRegex.Split(strings.TrimSpace(query), -1)
|
||||
for _, t := range terms {
|
||||
res = append(res, ILike("%"+EscapeLike(t)+"%", path...))
|
||||
}
|
||||
|
||||
return qm.Expr(res...)
|
||||
}
|
||||
|
||||
// EscapeLike escapes a string to be placed in an ILIKE query.
|
||||
func EscapeLike(val string) string {
|
||||
return likeQueryEscapeRegex.ReplaceAllString(val, "\\$1")
|
||||
}
|
||||
46
internal/util/db/ilike_test.go
Normal file
46
internal/util/db/ilike_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestILike(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.ILike("%Max.Muster%", "users", "username"),
|
||||
db.ILike("Max", "users", "app_user_profiles", "first_name"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestEscapeLike(t *testing.T) {
|
||||
res := db.EscapeLike("%foo% _b%a_r%")
|
||||
assert.Equal(t, "\\%foo\\% \\_b\\%a\\_r\\%", res)
|
||||
}
|
||||
|
||||
func TestILikeSearch(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.ILikeSearch(" mus%ter m_ax ", "users", "username"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
63
internal/util/db/join.go
Normal file
63
internal/util/db/join.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
// InnerJoinWithFilter returns an InnerJoin QueryMod formatted using the provided join tables and columns including an
|
||||
// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter.
|
||||
func InnerJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod {
|
||||
filterTable := joinTable
|
||||
if len(optFilterTable) > 0 {
|
||||
filterTable = optFilterTable[0]
|
||||
}
|
||||
|
||||
return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn,
|
||||
filterTable,
|
||||
filterColumn), filterValue)
|
||||
}
|
||||
|
||||
// InnerJoin returns an InnerJoin QueryMod formatted using the provided join tables and columns.
|
||||
func InnerJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod {
|
||||
return qm.InnerJoin(fmt.Sprintf("%s ON %s.%s=%s.%s",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn))
|
||||
}
|
||||
|
||||
// LeftOuterJoin returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns.
|
||||
func LeftOuterJoin(baseTable string, baseColumn string, joinTable string, joinColumn string) qm.QueryMod {
|
||||
return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn))
|
||||
}
|
||||
|
||||
// LeftOuterJoinWithFilter returns an LeftOuterJoin QueryMod formatted using the provided join tables and columns including an
|
||||
// additional filter condition. Omitting the optional filter table will use the provided join table as a base for the filter.
|
||||
func LeftOuterJoinWithFilter(baseTable string, baseColumn string, joinTable string, joinColumn string, filterColumn string, filterValue interface{}, optFilterTable ...string) qm.QueryMod {
|
||||
filterTable := joinTable
|
||||
if len(optFilterTable) > 0 {
|
||||
filterTable = optFilterTable[0]
|
||||
}
|
||||
|
||||
return qm.LeftOuterJoin(fmt.Sprintf("%s ON %s.%s=%s.%s AND %s.%s=?",
|
||||
joinTable,
|
||||
joinTable,
|
||||
joinColumn,
|
||||
baseTable,
|
||||
baseColumn,
|
||||
filterTable,
|
||||
filterColumn), filterValue)
|
||||
}
|
||||
104
internal/util/db/join_test.go
Normal file
104
internal/util/db/join_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestInnerJoinWithFilter(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
profiles, err := models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
models.UserColumns.Username,
|
||||
"user1@example.com",
|
||||
)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
|
||||
profiles, err = models.AppUserProfiles(db.InnerJoinWithFilter(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
models.UserColumns.Username,
|
||||
"user1@example.com",
|
||||
models.TableNames.Users,
|
||||
)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestInnerJoin(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
profiles, err := models.AppUserProfiles(db.InnerJoin(models.TableNames.AppUserProfiles,
|
||||
models.AppUserProfileColumns.UserID,
|
||||
models.TableNames.Users,
|
||||
models.UserColumns.ID,
|
||||
),
|
||||
models.UserWhere.Username.EQ(null.StringFrom("user1@example.com")),
|
||||
).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, profiles, 1)
|
||||
|
||||
assert.Equal(t, fix.User1AppUserProfile.UserID, profiles[0].UserID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLeftOuterJoinWithFilter(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
|
||||
query = models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoinWithFilter("users", "id", "app_user_profiles", "user_id", "first_name", "Max", "app_user_profiles"),
|
||||
)
|
||||
|
||||
sql, args = queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestLeftOuterJoin(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.LeftOuterJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
126
internal/util/db/json.go
Normal file
126
internal/util/db/json.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
const (
|
||||
whereJSONMaxLevel = 10
|
||||
)
|
||||
|
||||
// WhereJSON constructs a QueryMod for querying a JSONB column.
|
||||
//
|
||||
// The filter interface provided is inspected using reflection, all fields with
|
||||
// a (non-empty) `json` tag will be added to the query and combined using `AND` -
|
||||
// fields tagged with `json:"-"` will be ignored as well. Alternatively, a string
|
||||
// can be provided, performing a string comparison with the database value (the
|
||||
// stored JSON value does not necessarily have to be a string, but could be an
|
||||
// integer or similar). The `json` tag's (first) value will be used as the "key"
|
||||
// for the query, allowing for field renaming or different capitalizations.
|
||||
//
|
||||
// At the moment, the root level `filter` value must either be a struct or a string.
|
||||
// WhereJSON will panic should it encounter a type it cannot process or the filter
|
||||
// provided results in an empty QueryMod - this allows for easier call chaining
|
||||
// at the expense of panics in case of incorrect filters being passed.
|
||||
//
|
||||
// WhereJSON should support all basic types as well as pointers and array/slices
|
||||
// of those out of the box, given the Postgres driver can handle their serialization.
|
||||
// nil pointers are skipped automatically.
|
||||
// At the moment, struct fields are only supported for composition purposes: if a
|
||||
// struct is encountered, WhereJSON recursively traverses it (up to 10 levels deep)
|
||||
// and adds all eligible fields to the top level query.
|
||||
// Should an array or slice be encountered, their values will be added using the
|
||||
// `<@` JSONB operator, checking whether all entries existx at the top level within
|
||||
// the JSON column.
|
||||
// At the time of writing, no support for special database/HTTP types such as the
|
||||
// `null` or `strfmt` packages exists - use their respective base types instead.
|
||||
//
|
||||
// Whilst WhereJSON was designed to be used with Postgres' JSONB column type, the
|
||||
// current implementation also supports the JSON type as long as the filter struct
|
||||
// does not contain any arrays or slices. Note that this compatibility might change
|
||||
// at some point in the future, so it is advised to use the JSONB data type unless
|
||||
// your requirements do not allow for it.
|
||||
func WhereJSON(table string, column string, filter interface{}) qm.QueryMod {
|
||||
qms := whereJSON(table, column, filter, 0)
|
||||
if len(qms) == 0 {
|
||||
panic(errors.New("filter resulted in empty query"))
|
||||
}
|
||||
|
||||
return qm.Expr(qms...)
|
||||
}
|
||||
|
||||
func whereJSON(table string, column string, filter interface{}, level int) []qm.QueryMod {
|
||||
if level >= whereJSONMaxLevel {
|
||||
panic(fmt.Errorf("whereJSON reached maximum recursion (%d/%d)", level, whereJSONMaxLevel))
|
||||
}
|
||||
|
||||
qms := make([]qm.QueryMod, 0)
|
||||
|
||||
filterType := reflect.TypeOf(filter)
|
||||
switch filterType.Kind() {
|
||||
case reflect.Struct:
|
||||
filterValue := reflect.ValueOf(filter)
|
||||
for i := 0; i < filterType.NumField(); i++ {
|
||||
field := filterType.Field(i)
|
||||
|
||||
// skip unexported fields as we cannot retrieve their values
|
||||
if len(field.PkgPath) != 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
jsonkey := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||
if jsonkey == "-" {
|
||||
continue
|
||||
}
|
||||
|
||||
filterValueField := filterValue.Field(i)
|
||||
if filterValueField.Kind() != reflect.Struct && jsonkey == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
isArray := false
|
||||
var val interface{}
|
||||
switch filterValueField.Kind() {
|
||||
case reflect.Struct:
|
||||
qms = append(qms, whereJSON(table, column, filterValueField.Interface(), level+1)...)
|
||||
continue
|
||||
case reflect.Ptr:
|
||||
if !filterValueField.IsValid() || filterValueField.IsNil() {
|
||||
continue
|
||||
}
|
||||
if filterValueField.Elem().Kind() == reflect.Array ||
|
||||
filterValueField.Elem().Kind() == reflect.Slice {
|
||||
isArray = true
|
||||
}
|
||||
val = filterValueField.Elem().Interface()
|
||||
case reflect.Array,
|
||||
reflect.Slice:
|
||||
if !filterValueField.IsValid() || filterValueField.IsNil() {
|
||||
continue
|
||||
}
|
||||
isArray = true
|
||||
val = filterValueField.Interface()
|
||||
default:
|
||||
val = filterValueField.Interface()
|
||||
}
|
||||
|
||||
if isArray {
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->'%s' <@ to_jsonb(?::text[])", table, column, jsonkey), pq.Array(val)))
|
||||
} else {
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s->>'%s' = ?", table, column, jsonkey), val))
|
||||
}
|
||||
}
|
||||
case reflect.String:
|
||||
qms = append(qms, qm.Where(fmt.Sprintf("%s.%s::text = ?", table, column), filter))
|
||||
default:
|
||||
panic(fmt.Errorf("invalid filter type %v", filterType.Kind()))
|
||||
}
|
||||
|
||||
return qms
|
||||
}
|
||||
205
internal/util/db/json_test.go
Normal file
205
internal/util/db/json_test.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWhereJSONStruct(t *testing.T) {
|
||||
age := 42
|
||||
filter := struct {
|
||||
First string `json:"firstName"`
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
Country string `json:"country"`
|
||||
City string
|
||||
Scopes []string `json:"scopes"`
|
||||
Age *int `json:"age"`
|
||||
Height *float32 `json:"height"`
|
||||
PhoneNumbers *[2]string `json:"phoneNumbers"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}{
|
||||
First: "Max",
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
PhoneNumbers: &[2]string{
|
||||
"+1 206 555 0100",
|
||||
"+44 113 496 0000",
|
||||
},
|
||||
}
|
||||
|
||||
sql, args := buildWhereJSONQuery(t, filter)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONStructComposition(t *testing.T) {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
sql, args := buildWhereJSONQuery(t, filter)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONString(t *testing.T) {
|
||||
sql, args := buildWhereJSONQuery(t, "https://example.org/users/123/profile")
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicEmptyResult(t *testing.T) {
|
||||
type privateName struct {
|
||||
First string `json:"firstName"`
|
||||
MiddleName string `json:"-"`
|
||||
Lastname string `json:"lastName"`
|
||||
}
|
||||
|
||||
filter := struct {
|
||||
privateName
|
||||
City string
|
||||
}{
|
||||
privateName: privateName{
|
||||
First: "Max",
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
City: "Vienna",
|
||||
}
|
||||
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", filter)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "filter resulted in empty query", panicFunc)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicInvalidFilterType(t *testing.T) {
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", 1)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "invalid filter type int", panicFunc)
|
||||
}
|
||||
|
||||
func TestWhereJSONPanicRecursion(t *testing.T) {
|
||||
type A struct {
|
||||
One string `json:"one"`
|
||||
}
|
||||
type B struct {
|
||||
A
|
||||
Two string `json:"two"`
|
||||
}
|
||||
type C struct {
|
||||
B
|
||||
Three string `json:"three"`
|
||||
}
|
||||
type D struct {
|
||||
C
|
||||
Four string `json:"four"`
|
||||
}
|
||||
type E struct {
|
||||
D
|
||||
Five string `json:"five"`
|
||||
}
|
||||
type F struct {
|
||||
E
|
||||
Six string `json:"six"`
|
||||
}
|
||||
type G struct {
|
||||
F
|
||||
Seven string `json:"seven"`
|
||||
}
|
||||
type H struct {
|
||||
G
|
||||
Eight string `json:"eight"`
|
||||
}
|
||||
type I struct {
|
||||
H
|
||||
Nine string `json:"nine"`
|
||||
}
|
||||
type J struct {
|
||||
I
|
||||
Ten string `json:"ten"`
|
||||
}
|
||||
|
||||
filter := struct {
|
||||
J
|
||||
Country string `json:"country"`
|
||||
}{
|
||||
J: J{
|
||||
I: I{
|
||||
H: H{
|
||||
G: G{
|
||||
F: F{
|
||||
E: E{
|
||||
D: D{
|
||||
C: C{
|
||||
B: B{
|
||||
A: A{
|
||||
One: "1",
|
||||
},
|
||||
Two: "2",
|
||||
},
|
||||
Three: "3",
|
||||
},
|
||||
Four: "4",
|
||||
},
|
||||
Five: "5",
|
||||
},
|
||||
Six: "6",
|
||||
},
|
||||
Seven: "7",
|
||||
},
|
||||
Eight: "8",
|
||||
},
|
||||
Nine: "9",
|
||||
},
|
||||
Ten: "10",
|
||||
},
|
||||
Country: "Austria",
|
||||
}
|
||||
|
||||
panicFunc := func() {
|
||||
db.WhereJSON("users", "profile", filter)
|
||||
}
|
||||
|
||||
require.PanicsWithError(t, "whereJSON reached maximum recursion (10/10)", panicFunc)
|
||||
}
|
||||
|
||||
func buildWhereJSONQuery(t *testing.T, filter interface{}) (string, []interface{}) {
|
||||
t.Helper()
|
||||
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
)
|
||||
|
||||
return queries.BuildQuery(query)
|
||||
}
|
||||
22
internal/util/db/or.go
Normal file
22
internal/util/db/or.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package db
|
||||
|
||||
import "github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
|
||||
// CombineWithOr receives a slice of query mods and returns a new slice with
|
||||
// a single query mod, combining all other query mods into an OR expression.
|
||||
func CombineWithOr(qms []qm.QueryMod) []qm.QueryMod {
|
||||
if len(qms) == 0 {
|
||||
return []qm.QueryMod{}
|
||||
}
|
||||
|
||||
if len(qms) == 1 {
|
||||
return qms
|
||||
}
|
||||
|
||||
q := []qm.QueryMod{qms[0]}
|
||||
for _, sq := range qms[1:] {
|
||||
q = append(q, qm.Or2(sq))
|
||||
}
|
||||
|
||||
return []qm.QueryMod{qm.Expr(q...)}
|
||||
}
|
||||
67
internal/util/db/or_test.go
Normal file
67
internal/util/db/or_test.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOr(t *testing.T) {
|
||||
age := 42
|
||||
filter := UserFilter{
|
||||
Name: Name{
|
||||
PublicName: PublicName{
|
||||
First: "Max",
|
||||
},
|
||||
MiddleName: "Gustav",
|
||||
Lastname: "Muster",
|
||||
},
|
||||
Country: "Austria",
|
||||
City: "Vienna",
|
||||
Scopes: []string{"app", "user_info"},
|
||||
Age: &age,
|
||||
}
|
||||
|
||||
qms := []qm.QueryMod{
|
||||
qm.Where("id = ?", 123),
|
||||
qm.Where("username = ?", "max.muster@example.org"),
|
||||
db.WhereJSON("users", "profile", filter),
|
||||
}
|
||||
sql, args := buildOrQuery(t, qms)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestOrSingle(t *testing.T) {
|
||||
q := qm.Where("username = ?", "max.muster@example.org")
|
||||
qms := db.CombineWithOr([]qm.QueryMod{q})
|
||||
require.Len(t, qms, 1)
|
||||
assert.Equal(t, q, qms[0])
|
||||
}
|
||||
|
||||
func TestOrEmpty(t *testing.T) {
|
||||
qms := db.CombineWithOr([]qm.QueryMod{})
|
||||
assert.Empty(t, qms)
|
||||
|
||||
qms = db.CombineWithOr(nil)
|
||||
assert.Empty(t, qms)
|
||||
}
|
||||
|
||||
func buildOrQuery(t *testing.T, qms []qm.QueryMod) (string, []interface{}) {
|
||||
t.Helper()
|
||||
|
||||
o := db.CombineWithOr(qms)
|
||||
require.NotEmpty(t, o)
|
||||
|
||||
o = append(o, qm.Select("*"), qm.From("users"))
|
||||
q := models.NewQuery(o...)
|
||||
|
||||
return queries.BuildQuery(q)
|
||||
}
|
||||
32
internal/util/db/order_by.go
Normal file
32
internal/util/db/order_by.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
func OrderBy(orderDir types.OrderDir, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("%s %s", strings.Join(path, "."), strings.ToUpper(string(orderDir))))
|
||||
}
|
||||
|
||||
func OrderByLower(orderDir types.OrderDir, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s", strings.Join(path, "."), strings.ToUpper(string(orderDir))))
|
||||
}
|
||||
|
||||
type OrderByNulls string
|
||||
|
||||
const (
|
||||
OrderByNullsFirst OrderByNulls = "FIRST"
|
||||
OrderByNullsLast OrderByNulls = "LAST"
|
||||
)
|
||||
|
||||
func OrderByWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("%s %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls))
|
||||
}
|
||||
|
||||
func OrderByLowerWithNulls(orderDir types.OrderDir, orderByNulls OrderByNulls, path ...string) qm.QueryMod {
|
||||
return qm.OrderBy(fmt.Sprintf("LOWER(%s) %s NULLS %s", strings.Join(path, "."), strings.ToUpper(string(orderDir)), orderByNulls))
|
||||
}
|
||||
68
internal/util/db/order_by_test.go
Normal file
68
internal/util/db/order_by_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test/fixtures"
|
||||
swaggerTypes "allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/null/v8"
|
||||
"github.com/aarondl/sqlboiler/v4/boil"
|
||||
"github.com/aarondl/sqlboiler/v4/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOrderBy(t *testing.T) {
|
||||
test.WithTestDatabase(t, func(sqlDB *sql.DB) {
|
||||
ctx := t.Context()
|
||||
fix := fixtures.Fixtures()
|
||||
|
||||
_, err := fix.UserRequiresConfirmation.Delete(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
|
||||
noUsername := models.User{
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
upperUsername := models.User{
|
||||
Username: null.StringFrom("USER3@example.com"),
|
||||
Scopes: types.StringArray{"cms"},
|
||||
}
|
||||
|
||||
err = noUsername.Insert(ctx, sqlDB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
err = upperUsername.Insert(ctx, sqlDB, boil.Infer())
|
||||
require.NoError(t, err)
|
||||
|
||||
users, err := models.Users(db.OrderBy(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, upperUsername.ID, users[0].ID)
|
||||
assert.Equal(t, upperUsername.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByLower(swaggerTypes.OrderDirAsc, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, fix.User1.ID, users[0].ID)
|
||||
assert.Equal(t, fix.User1.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByWithNulls(swaggerTypes.OrderDirAsc, db.OrderByNullsFirst, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, noUsername.ID, users[0].ID)
|
||||
assert.Equal(t, noUsername.Username, users[0].Username)
|
||||
|
||||
users, err = models.Users(db.OrderByLowerWithNulls(swaggerTypes.OrderDirDesc, db.OrderByNullsLast, models.TableNames.Users, models.UserColumns.Username)).All(ctx, sqlDB)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, users)
|
||||
assert.Equal(t, fix.UserDeactivated.ID, users[0].ID)
|
||||
assert.Equal(t, fix.UserDeactivated.Username, users[0].Username)
|
||||
assert.Equal(t, upperUsername.ID, users[1].ID)
|
||||
assert.Equal(t, upperUsername.Username, users[1].Username)
|
||||
})
|
||||
}
|
||||
17
internal/util/db/query_mods.go
Normal file
17
internal/util/db/query_mods.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
// QueryMods represents a slice of query mods, implementing the `queries.Applicator`
|
||||
// interface to allow for usage with eager loading methods of models. Unfortunately,
|
||||
// sqlboiler does not import the (identical) type used by the library, so we have to
|
||||
// declare and "implemented" it ourselves...
|
||||
type QueryMods []qm.QueryMod
|
||||
|
||||
// Apply applies the query mods to the query provided
|
||||
func (m QueryMods) Apply(q *queries.Query) {
|
||||
qm.Apply(q, m...)
|
||||
}
|
||||
28
internal/util/db/ts_vector.go
Normal file
28
internal/util/db/ts_vector.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
tsQueryWhiteSpaceRegex = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// SearchStringToTSQuery returns a TSQuery string from user input.
|
||||
// The resulting query will match if every word matches a beginning of a word in the row.
|
||||
// This function will trim all leading and trailing as well as consecutive whitespaces and remove all single quotes before
|
||||
// transforming the input into TSQuery syntax.
|
||||
// If no input was given (nil or empty string) or the value only contains invalid characters, an empty string will be returned.
|
||||
func SearchStringToTSQuery(s *string) string {
|
||||
if s == nil || len(*s) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
v := strings.TrimSpace(strings.ReplaceAll(*s, "'", ""))
|
||||
if len(v) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "'" + tsQueryWhiteSpaceRegex.ReplaceAllString(v, "':* & '") + "':*"
|
||||
}
|
||||
41
internal/util/db/ts_vector_test.go
Normal file
41
internal/util/db/ts_vector_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSearchStringToTSQuery(t *testing.T) {
|
||||
expected := "'abcde':* & '12345':* & 'xyz':*"
|
||||
search := swag.String(" abcde 12345 xyz ")
|
||||
out := db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = "'abcde':*"
|
||||
search = swag.String("abcde")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = "'Hello':* & 'world':* & 'lorem':* & '12345':* & 'ipsum':* & 'abc':* & 'def':*"
|
||||
search = swag.String(" Hello world lorem 12345 ipsum abc def ")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = nil
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = swag.String("")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
|
||||
expected = ""
|
||||
search = swag.String(" '' ' ' ' ")
|
||||
out = db.SearchStringToTSQuery(search)
|
||||
assert.Equal(t, expected, out)
|
||||
}
|
||||
25
internal/util/db/where_in.go
Normal file
25
internal/util/db/where_in.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// WhereIn was a copy from sqlboiler's WHERE IN query helpers since these don't get generated for nullable columns.
|
||||
// Since sqlboilers IN query helpers will set a param for earch element in the slice we reccomment using this packages IN.
|
||||
func WhereIn(tableName string, columnName string, slice []string) qm.QueryMod {
|
||||
return IN(fmt.Sprintf("%s.%s", tableName, columnName), slice)
|
||||
}
|
||||
|
||||
// IN is a replacement for sqlboilers IN query mod. sqlboilers IN will set a param for
|
||||
// each element in the slice and we do not recommend to use this, because it will run into driver and
|
||||
// database limits. While the sqlboiler IN fails at about ~10000 params this was tested with over 1000000.
|
||||
func IN(path string, slice []string) qm.QueryMod {
|
||||
return qm.Where(fmt.Sprintf("%s = any(?)", path), pq.StringArray(slice))
|
||||
}
|
||||
|
||||
func NIN(path string, slice []string) qm.QueryMod {
|
||||
return qm.Where(fmt.Sprintf("%s <> all(?)", path), pq.StringArray(slice))
|
||||
}
|
||||
39
internal/util/db/where_in_test.go
Normal file
39
internal/util/db/where_in_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package db_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/models"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/db"
|
||||
"github.com/aarondl/sqlboiler/v4/queries"
|
||||
"github.com/aarondl/sqlboiler/v4/queries/qm"
|
||||
)
|
||||
|
||||
func TestWhereIn(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.WhereIn("app_user_profiles", "username", []string{"max", "muster", "peter"}),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
|
||||
func TestNIN(t *testing.T) {
|
||||
query := models.NewQuery(
|
||||
qm.Select("*"),
|
||||
qm.From("users"),
|
||||
db.InnerJoin("users", "id", "app_user_profiles", "user_id"),
|
||||
db.NIN("app_user_profiles.username", []string{"max", "muster", "peter"}),
|
||||
)
|
||||
|
||||
sql, args := queries.BuildQuery(query)
|
||||
|
||||
test.Snapshoter.Label("SQL").Save(t, sql)
|
||||
test.Snapshoter.Label("Args").Save(t, args...)
|
||||
}
|
||||
223
internal/util/env.go
Normal file
223
internal/util/env.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
const (
|
||||
mgmtSecretLen = 16
|
||||
)
|
||||
|
||||
var (
|
||||
mgmtSecret string
|
||||
mgmtSecretOnce sync.Once
|
||||
)
|
||||
|
||||
func GetEnv(key string, defaultVal string) string {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return val
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func GetEnvEnum(key string, defaultVal string, allowedValues []string) string {
|
||||
if !slices.Contains(allowedValues, defaultVal) {
|
||||
log.Panic().Str("key", key).Str("value", defaultVal).Msg("Default value is not in the allowed values list.")
|
||||
}
|
||||
|
||||
val, ok := os.LookupEnv(key)
|
||||
if !ok {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
if !slices.Contains(allowedValues, val) {
|
||||
log.Error().Str("key", key).Str("value", val).Msg("Value is not allowed. Fallback to default value.")
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
return val
|
||||
}
|
||||
|
||||
func GetEnvAsInt(key string, defaultVal int) int {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if val, err := strconv.Atoi(strVal); err == nil {
|
||||
return val
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func GetEnvAsUint32(key string, defaultVal uint32) uint32 {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if val, err := strconv.ParseUint(strVal, 10, 32); err == nil {
|
||||
return uint32(val)
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func GetEnvAsUint8(key string, defaultVal uint8) uint8 {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if val, err := strconv.ParseUint(strVal, 10, 8); err == nil {
|
||||
return uint8(val)
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
func GetEnvAsBool(key string, defaultVal bool) bool {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if val, err := strconv.ParseBool(strVal); err == nil {
|
||||
return val
|
||||
}
|
||||
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// GetEnvAsStringArr reads ENV and returns the values split by separator.
|
||||
func GetEnvAsStringArr(key string, defaultVal []string, separator ...string) []string {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if len(strVal) == 0 {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
sep := ","
|
||||
if len(separator) >= 1 {
|
||||
sep = separator[0]
|
||||
}
|
||||
|
||||
return strings.Split(strVal, sep)
|
||||
}
|
||||
|
||||
// GetEnvAsStringArrTrimmed reads ENV and returns the whitespace trimmed values split by separator.
|
||||
func GetEnvAsStringArrTrimmed(key string, defaultVal []string, separator ...string) []string {
|
||||
slc := GetEnvAsStringArr(key, defaultVal, separator...)
|
||||
|
||||
for i := range slc {
|
||||
slc[i] = strings.TrimSpace(slc[i])
|
||||
}
|
||||
|
||||
return slc
|
||||
}
|
||||
|
||||
func GetEnvAsURL(key string, defaultVal string) *url.URL {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if len(strVal) == 0 {
|
||||
u, err := url.Parse(defaultVal)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("defaultVal", defaultVal).Err(err).Msg("Failed to parse default value for env variable as URL")
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
u, err := url.Parse(strVal)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as URL")
|
||||
}
|
||||
|
||||
return u
|
||||
}
|
||||
|
||||
func GetEnvAsLanguageTag(key string, defaultVal language.Tag) language.Tag {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if len(strVal) == 0 {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
tag, err := language.Parse(strVal)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as language.Tag")
|
||||
}
|
||||
|
||||
return tag
|
||||
}
|
||||
|
||||
// GetEnvAsLanguageTagArr reads ENV and returns the parsed values as []language.Tag split by separator.
|
||||
func GetEnvAsLanguageTagArr(key string, defaultVal []language.Tag, separator ...string) []language.Tag {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if len(strVal) == 0 {
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
sep := ","
|
||||
if len(separator) >= 1 {
|
||||
sep = separator[0]
|
||||
}
|
||||
|
||||
splitString := strings.Split(strVal, sep)
|
||||
res := []language.Tag{}
|
||||
for _, s := range splitString {
|
||||
tag, err := language.Parse(s)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("itemVal", s).Err(err).Msg("Failed to parse item value from env variable as language.Tag")
|
||||
}
|
||||
res = append(res, tag)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// GetMgmtSecret returns the management secret for the app server, mainly used by health check and readiness endpoints.
|
||||
// It first attempts to retrieve a value from the given environment variable and generates a cryptographically secure random string
|
||||
// should no env var have been set.
|
||||
// Failure to generate a random string will cause a panic as secret security cannot be guaranteed otherwise.
|
||||
// Subsequent calls to GetMgmtSecret during the server's runtime will always return the same randomly generated secret for consistency.
|
||||
func GetMgmtSecret(envKey string) string {
|
||||
val := GetEnv(envKey, "")
|
||||
|
||||
if len(val) > 0 {
|
||||
return val
|
||||
}
|
||||
|
||||
mgmtSecretOnce.Do(func() {
|
||||
var err error
|
||||
mgmtSecret, err = GenerateRandomHexString(mgmtSecretLen)
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to generate random management secret")
|
||||
}
|
||||
|
||||
log.Warn().Str("envKey", envKey).Str("mgmtSecret", mgmtSecret).Msg("Could not retrieve management secret from env key, using randomly generated one")
|
||||
})
|
||||
|
||||
return mgmtSecret
|
||||
}
|
||||
|
||||
func GetEnvAsLocation(key string, defaultVal string) *time.Location {
|
||||
strVal := GetEnv(key, "")
|
||||
|
||||
if len(strVal) == 0 {
|
||||
l, err := time.LoadLocation(defaultVal)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("defaultVal", defaultVal).Err(err).Msg("Failed to parse default value for env variable as location")
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
l, err := time.LoadLocation(strVal)
|
||||
if err != nil {
|
||||
log.Panic().Str("key", key).Str("strVal", strVal).Err(err).Msg("Failed to parse env variable as location")
|
||||
}
|
||||
|
||||
return l
|
||||
}
|
||||
278
internal/util/env_test.go
Normal file
278
internal/util/env_test.go
Normal file
@@ -0,0 +1,278 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestGetEnv(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING"
|
||||
res := util.GetEnv(testVarKey, "noVal")
|
||||
assert.Equal(t, "noVal", res)
|
||||
|
||||
t.Setenv(testVarKey, "string")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnv(testVarKey, "noVal")
|
||||
assert.Equal(t, "string", res)
|
||||
}
|
||||
|
||||
func TestGetEnvEnum(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_ENUM"
|
||||
|
||||
panicFunc := func() {
|
||||
_ = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "foo"})
|
||||
}
|
||||
assert.Panics(t, panicFunc)
|
||||
|
||||
res := util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"})
|
||||
assert.Equal(t, "smtp", res)
|
||||
|
||||
t.Setenv(testVarKey, "mock")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"})
|
||||
assert.Equal(t, "mock", res)
|
||||
|
||||
t.Setenv(testVarKey, "foo")
|
||||
res = util.GetEnvEnum(testVarKey, "smtp", []string{"mock", "smtp"})
|
||||
assert.Equal(t, "smtp", res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsInt(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_INT"
|
||||
res := util.GetEnvAsInt(testVarKey, 1)
|
||||
assert.Equal(t, 1, res)
|
||||
|
||||
t.Setenv(testVarKey, "2")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsInt(testVarKey, 1)
|
||||
assert.Equal(t, 2, res)
|
||||
|
||||
t.Setenv(testVarKey, "3x")
|
||||
res = util.GetEnvAsInt(testVarKey, 1)
|
||||
assert.Equal(t, 1, res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsUint32(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_UINT32"
|
||||
res := util.GetEnvAsUint32(testVarKey, 1)
|
||||
assert.Equal(t, uint32(1), res)
|
||||
|
||||
t.Setenv(testVarKey, "2")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsUint32(testVarKey, 1)
|
||||
assert.Equal(t, uint32(2), res)
|
||||
|
||||
t.Setenv(testVarKey, "3x")
|
||||
res = util.GetEnvAsUint32(testVarKey, 1)
|
||||
assert.Equal(t, uint32(1), res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsUint8(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_UINT8"
|
||||
res := util.GetEnvAsUint8(testVarKey, 1)
|
||||
assert.Equal(t, uint8(1), res)
|
||||
|
||||
t.Setenv(testVarKey, "2")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsUint8(testVarKey, 1)
|
||||
assert.Equal(t, uint8(2), res)
|
||||
|
||||
t.Setenv(testVarKey, "3x")
|
||||
res = util.GetEnvAsUint8(testVarKey, 1)
|
||||
assert.Equal(t, uint8(1), res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsBool(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_BOOL"
|
||||
res := util.GetEnvAsBool(testVarKey, true)
|
||||
assert.True(t, res)
|
||||
|
||||
t.Setenv(testVarKey, "f")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsBool(testVarKey, true)
|
||||
assert.False(t, res)
|
||||
|
||||
t.Setenv(testVarKey, "0")
|
||||
res = util.GetEnvAsBool(testVarKey, true)
|
||||
assert.False(t, res)
|
||||
|
||||
t.Setenv(testVarKey, "false")
|
||||
res = util.GetEnvAsBool(testVarKey, true)
|
||||
assert.False(t, res)
|
||||
|
||||
t.Setenv(testVarKey, "3x")
|
||||
res = util.GetEnvAsBool(testVarKey, true)
|
||||
assert.True(t, res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsURL(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_URL"
|
||||
testURL, err := url.Parse("https://allaboutapps.at/")
|
||||
require.NoError(t, err)
|
||||
|
||||
panicFunc := func() {
|
||||
_ = util.GetEnvAsURL(testVarKey, "%")
|
||||
}
|
||||
assert.Panics(t, panicFunc)
|
||||
|
||||
res := util.GetEnvAsURL(testVarKey, "https://allaboutapps.at/")
|
||||
assert.Equal(t, *testURL, *res)
|
||||
|
||||
t.Setenv(testVarKey, "https://allaboutapps.at/")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsURL(testVarKey, "foo")
|
||||
assert.Equal(t, *testURL, *res)
|
||||
|
||||
t.Setenv(testVarKey, "%")
|
||||
panicFunc = func() {
|
||||
_ = util.GetEnvAsURL(testVarKey, "https://allaboutapps.at/")
|
||||
}
|
||||
assert.Panics(t, panicFunc)
|
||||
}
|
||||
|
||||
func TestGetEnvAsStringArr(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING_ARR"
|
||||
testVal := []string{"a", "b", "c"}
|
||||
res := util.GetEnvAsStringArr(testVarKey, testVal)
|
||||
assert.Equal(t, testVal, res)
|
||||
|
||||
t.Setenv(testVarKey, "1,2")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal)
|
||||
assert.Equal(t, []string{"1", "2"}, res)
|
||||
|
||||
t.Setenv(testVarKey, "")
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal)
|
||||
assert.Equal(t, testVal, res)
|
||||
|
||||
t.Setenv(testVarKey, "a, b, c")
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal)
|
||||
assert.Equal(t, []string{"a", " b", " c"}, res)
|
||||
|
||||
t.Setenv(testVarKey, "a|b|c")
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal, "|")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, res)
|
||||
|
||||
t.Setenv(testVarKey, "a,b,c")
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal, "|")
|
||||
assert.Equal(t, []string{"a,b,c"}, res)
|
||||
|
||||
t.Setenv(testVarKey, "a||b||c")
|
||||
res = util.GetEnvAsStringArr(testVarKey, testVal, "||")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsStringArrTrimmed(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_STRING_ARR_TRIMMED"
|
||||
testVal := []string{"a", "b", "c"}
|
||||
|
||||
t.Setenv(testVarKey, "a, b, c")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res := util.GetEnvAsStringArrTrimmed(testVarKey, testVal)
|
||||
assert.Equal(t, []string{"a", "b", "c"}, res)
|
||||
|
||||
t.Setenv(testVarKey, "a, b,c ")
|
||||
res = util.GetEnvAsStringArrTrimmed(testVarKey, testVal)
|
||||
assert.Equal(t, []string{"a", "b", "c"}, res)
|
||||
|
||||
t.Setenv(testVarKey, " a || b || c ")
|
||||
res = util.GetEnvAsStringArrTrimmed(testVarKey, testVal, "||")
|
||||
assert.Equal(t, []string{"a", "b", "c"}, res)
|
||||
}
|
||||
|
||||
func TestGetMgmtSecret(t *testing.T) {
|
||||
rs, err := util.GenerateRandomHexString(8)
|
||||
require.NoError(t, err)
|
||||
|
||||
key := fmt.Sprintf("WE_WILL_NEVER_USE_THIS_MGMT_SECRET_%s", rs)
|
||||
expectedVal := fmt.Sprintf("SUPER_SECRET_%s", rs)
|
||||
|
||||
t.Setenv(key, expectedVal)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
val := util.GetMgmtSecret(key)
|
||||
assert.Equal(t, expectedVal, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvAsLanguageTag(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LANG"
|
||||
res := util.GetEnvAsLanguageTag(testVarKey, language.German)
|
||||
assert.Equal(t, language.German, res)
|
||||
|
||||
t.Setenv(testVarKey, "en")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsLanguageTag(testVarKey, language.German)
|
||||
assert.Equal(t, language.English, res)
|
||||
}
|
||||
|
||||
func TestGetEnvAsLanguageTagArr(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LANG_ARR"
|
||||
testVal := []language.Tag{language.German, language.English, language.Spanish}
|
||||
res := util.GetEnvAsLanguageTagArr(testVarKey, testVal)
|
||||
assert.Equal(t, testVal, res)
|
||||
|
||||
t.Setenv(testVarKey, "de,en")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsLanguageTagArr(testVarKey, testVal)
|
||||
assert.Equal(t, []language.Tag{language.German, language.English}, res)
|
||||
|
||||
t.Setenv(testVarKey, "")
|
||||
res = util.GetEnvAsLanguageTagArr(testVarKey, testVal)
|
||||
assert.Equal(t, testVal, res)
|
||||
|
||||
t.Setenv(testVarKey, "en|es")
|
||||
res = util.GetEnvAsLanguageTagArr(testVarKey, testVal, "|")
|
||||
assert.Equal(t, []language.Tag{language.English, language.Spanish}, res)
|
||||
|
||||
t.Setenv(testVarKey, "en||es")
|
||||
res = util.GetEnvAsLanguageTagArr(testVarKey, testVal, "||")
|
||||
assert.Equal(t, []language.Tag{language.English, language.Spanish}, res)
|
||||
}
|
||||
|
||||
func TestGetMgmtSecretRandom(t *testing.T) {
|
||||
expectedVal := util.GetMgmtSecret("DOES_NOT_EXIST_MGMT_SECRET")
|
||||
require.NotEmpty(t, expectedVal)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
val := util.GetMgmtSecret("DOES_NOT_EXIST_MGMT_SECRET")
|
||||
assert.Equal(t, expectedVal, val)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetEnvAsLocation(t *testing.T) {
|
||||
testVarKey := "TEST_ONLY_FOR_UNIT_TEST_LOCATION"
|
||||
res := util.GetEnvAsLocation(testVarKey, "UTC")
|
||||
assert.Equal(t, time.UTC, res)
|
||||
|
||||
t.Setenv(testVarKey, "Local")
|
||||
defer os.Unsetenv(testVarKey)
|
||||
res = util.GetEnvAsLocation(testVarKey, "UTC")
|
||||
assert.Equal(t, time.Local, res)
|
||||
|
||||
vienna, err := time.LoadLocation("Europe/Vienna")
|
||||
require.NoError(t, err)
|
||||
t.Setenv(testVarKey, "Europe/Vienna")
|
||||
res = util.GetEnvAsLocation(testVarKey, "UTC")
|
||||
assert.Equal(t, vienna, res)
|
||||
|
||||
panicFunc := func() {
|
||||
t.Setenv(testVarKey, "")
|
||||
_ = util.GetEnvAsLocation(testVarKey, "not-valud")
|
||||
}
|
||||
assert.Panics(t, panicFunc)
|
||||
|
||||
panicFunc = func() {
|
||||
t.Setenv(testVarKey, "not-valid")
|
||||
_ = util.GetEnvAsLocation(testVarKey, "UTC")
|
||||
}
|
||||
assert.Panics(t, panicFunc)
|
||||
}
|
||||
39
internal/util/fs.go
Normal file
39
internal/util/fs.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TouchFile creates an empty file if the file doesn’t already exist.
|
||||
// If the file already exists then TouchFile updates the modified time of the file.
|
||||
// Returns the modification time of the created / updated file.
|
||||
func TouchFile(absolutePathToFile string) (time.Time, error) {
|
||||
_, err := os.Stat(absolutePathToFile)
|
||||
|
||||
if os.IsNotExist(err) {
|
||||
file, err := os.Create(absolutePathToFile)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to create file: %w", err)
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to stat file: %w", err)
|
||||
}
|
||||
|
||||
return stat.ModTime(), nil
|
||||
}
|
||||
|
||||
currentTime := time.Now().Local()
|
||||
err = os.Chtimes(absolutePathToFile, currentTime, currentTime)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to change file time: %w", err)
|
||||
}
|
||||
|
||||
return currentTime, nil
|
||||
}
|
||||
28
internal/util/fs_test.go
Normal file
28
internal/util/fs_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTouchfile(t *testing.T) {
|
||||
err := os.Remove("/tmp/.touchfile-test")
|
||||
if err != nil {
|
||||
require.Truef(t, os.IsNotExist(err), "Only permitting os.IsNotExist(err) as file may not preexistant on test start, but is: %v", err)
|
||||
}
|
||||
|
||||
ts1, err := util.TouchFile("/tmp/.touchfile-test")
|
||||
require.NoError(t, err)
|
||||
|
||||
ts2, err := util.TouchFile("/tmp/.touchfile-test")
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, ts1.UnixNano(), ts2.UnixNano())
|
||||
|
||||
zeroTime, err := util.TouchFile("/this/path/does/not/exist/.touchfile-test")
|
||||
require.Error(t, err)
|
||||
assert.True(t, zeroTime.IsZero(), "time.Time on error should be zero time")
|
||||
}
|
||||
36
internal/util/get_project_root_dir.go
Normal file
36
internal/util/get_project_root_dir.go
Normal file
@@ -0,0 +1,36 @@
|
||||
//go:build !scripts
|
||||
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var (
|
||||
projectRootDir string
|
||||
dirOnce sync.Once
|
||||
)
|
||||
|
||||
// GetProjectRootDir returns the path as string to the project_root for a **running application**.
|
||||
// Note: This function should not be used for generation targets (go generate, make go-generate).
|
||||
// Thus it's explicitly excluded from the build tag scripts, see instead:
|
||||
// * /scripts/get_project_root_dir.go
|
||||
// * ./get_project_root_dir_scripts.go (delegates to above)
|
||||
// https://stackoverflow.com/questions/43215655/building-multiple-binaries-using-different-packages-and-build-tags
|
||||
func GetProjectRootDir() string {
|
||||
dirOnce.Do(func() {
|
||||
ex, err := os.Executable()
|
||||
if err != nil {
|
||||
log.Panic().Err(err).Msg("Failed to get executable path while retrieving project root directory")
|
||||
}
|
||||
|
||||
projectRootDir = GetEnv("PROJECT_ROOT_DIR", filepath.Dir(ex))
|
||||
})
|
||||
|
||||
return projectRootDir
|
||||
}
|
||||
21
internal/util/get_project_root_dir_scripts.go
Normal file
21
internal/util/get_project_root_dir_scripts.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build scripts
|
||||
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import "os"
|
||||
|
||||
// Note that VSCode/gopls currently spawns a "No packages found for open file: [...]" here.
|
||||
// This is expected and will go away with gopls v1.0, see https://github.com/golang/go/issues/29202
|
||||
|
||||
// GetProjectRootDir returns the path as string to the project_root while **scripts generation**.
|
||||
// Note: This function replaces the original util.GetProjectRootDir when go runs with the "script" build tag.
|
||||
// https://stackoverflow.com/questions/43215655/building-multiple-binaries-using-different-packages-and-build-tags
|
||||
// Should be in sync with "scripts/internal/util/get_project_root_dir.go"
|
||||
func GetProjectRootDir() string {
|
||||
if val, ok := os.LookupEnv("PROJECT_ROOT_DIR"); ok {
|
||||
return val
|
||||
}
|
||||
|
||||
return "/app"
|
||||
}
|
||||
114
internal/util/hashing/argon2.go
Normal file
114
internal/util/hashing/argon2.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package hashing
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// Inspired by: https://github.com/alexedwards/argon2id @ 2020-04-22T14:13:23ZZ
|
||||
|
||||
const (
|
||||
// Argon2HashID represents the hash ID set in the (pseudo) modular crypt format used to store the hashed password and params in a single string.
|
||||
Argon2HashID = "argon2id"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidArgon2Hash indicates the argon2id hash was malformed and could not be decoded.
|
||||
ErrInvalidArgon2Hash = errors.New("invalid argon2id hash")
|
||||
// ErrIncompatibleArgon2Version indicates the argon2id hash provided was generated with a different, incompatible argon2 version.
|
||||
ErrIncompatibleArgon2Version = errors.New("incompatible argon2 version")
|
||||
)
|
||||
|
||||
func HashPassword(password string, params *Argon2Params) (string, error) {
|
||||
salt, err := generateSalt(params.SaltLength)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
key := argon2.IDKey([]byte(password), salt, params.Time, params.Memory, params.Threads, params.KeyLength)
|
||||
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Key := base64.RawStdEncoding.EncodeToString(key)
|
||||
|
||||
return fmt.Sprintf("$%s$v=%d$m=%d,t=%d,p=%d$%s$%s", Argon2HashID, argon2.Version, params.Memory, params.Time, params.Threads, b64Salt, b64Key), nil
|
||||
}
|
||||
|
||||
func ComparePasswordAndHash(password string, hash string) (bool, error) {
|
||||
params, salt, key, err := decodeArgon2Hash(hash)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
pKey := argon2.IDKey([]byte(password), salt, params.Time, params.Memory, params.Threads, params.KeyLength)
|
||||
|
||||
keyLen := len(key)
|
||||
pKeyLen := len(pKey)
|
||||
|
||||
if keyLen > math.MaxInt32 || pKeyLen > math.MaxInt32 {
|
||||
return false, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeEq(int32(keyLen), int32(pKeyLen)) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
if subtle.ConstantTimeCompare(key, pKey) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
const (
|
||||
argon2HashParts = 6
|
||||
)
|
||||
|
||||
func decodeArgon2Hash(hash string) (*Argon2Params, []byte, []byte, error) {
|
||||
vals := strings.Split(hash, "$") // splits into array of 6 values, with val[0] being empty --> length/indicies "offset" by one
|
||||
if len(vals) != argon2HashParts {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
if vals[1] != Argon2HashID {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
var version int
|
||||
_, err := fmt.Sscanf(vals[2], "v=%d", &version)
|
||||
if err != nil {
|
||||
return nil, nil, nil, ErrIncompatibleArgon2Version
|
||||
}
|
||||
if version != argon2.Version {
|
||||
return nil, nil, nil, ErrIncompatibleArgon2Version
|
||||
}
|
||||
|
||||
params := &Argon2Params{}
|
||||
_, err = fmt.Sscanf(vals[3], "m=%d,t=%d,p=%d", ¶ms.Memory, ¶ms.Time, ¶ms.Threads)
|
||||
if err != nil {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(vals[4])
|
||||
if err != nil {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
key, err := base64.RawStdEncoding.DecodeString(vals[5])
|
||||
if err != nil {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
keyLength := len(key)
|
||||
if keyLength > math.MaxInt32 {
|
||||
return nil, nil, nil, ErrInvalidArgon2Hash
|
||||
}
|
||||
|
||||
params.KeyLength = uint32(keyLength)
|
||||
|
||||
return params, salt, key, nil
|
||||
}
|
||||
39
internal/util/hashing/argon2_params.go
Normal file
39
internal/util/hashing/argon2_params.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package hashing
|
||||
|
||||
import "allaboutapps.dev/aw/go-starter/internal/util"
|
||||
|
||||
var (
|
||||
// DefaultArgon2Params represents Argon2ID parameter recommendations in accordance with:
|
||||
// https://pkg.go.dev/golang.org/x/crypto@v0.0.0-20200420201142-3c4aac89819a/argon2?tab=doc#IDKey @ 2020-04-22T11:23:38Z
|
||||
DefaultArgon2Params = &Argon2Params{
|
||||
Time: 1, // 1 second
|
||||
//nolint:mnd
|
||||
Memory: 64 * 1024, // ~64MB memory costs
|
||||
//nolint:mnd
|
||||
Threads: 4, // 4 threads
|
||||
//nolint:mnd
|
||||
KeyLength: 32, // 256 bit key length
|
||||
//nolint:mnd
|
||||
SaltLength: 16, // 126 bit salt length
|
||||
}
|
||||
)
|
||||
|
||||
type Argon2Params struct {
|
||||
Time uint32
|
||||
Memory uint32
|
||||
Threads uint8
|
||||
KeyLength uint32
|
||||
SaltLength uint32
|
||||
}
|
||||
|
||||
func DefaultArgon2ParamsFromEnv() *Argon2Params {
|
||||
params := &Argon2Params{
|
||||
Time: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_TIME", DefaultArgon2Params.Time),
|
||||
Memory: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_MEMORY", DefaultArgon2Params.Memory),
|
||||
Threads: util.GetEnvAsUint8("AUTH_HASHING_ARGON2_THREADS", DefaultArgon2Params.Threads),
|
||||
KeyLength: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_KEY_LENGTH", DefaultArgon2Params.KeyLength),
|
||||
SaltLength: util.GetEnvAsUint32("AUTH_HASHING_ARGON2_SALT_LENGTH", DefaultArgon2Params.SaltLength),
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
106
internal/util/hashing/argon2_test.go
Normal file
106
internal/util/hashing/argon2_test.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package hashing_test
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/hashing"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHashPassword(t *testing.T) {
|
||||
hashRegex, err := regexp.Compile(`^\$argon2id\$v=19\$m=65536,t=1,p=4\$[A-Za-z0-9+/]{22}\$[A-Za-z0-9+/]{43}$`)
|
||||
require.NoError(t, err, "failed to compile hash regex")
|
||||
|
||||
hash1, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params)
|
||||
require.NoError(t, err, "failed to hash password")
|
||||
|
||||
assert.Truef(t, hashRegex.MatchString(hash1), "hash %q is not formatted properly", hash1)
|
||||
|
||||
hash2, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params)
|
||||
require.NoError(t, err, "failed to hash password")
|
||||
|
||||
assert.Truef(t, hashRegex.MatchString(hash2), "hash %q is not formatted properly", hash2)
|
||||
|
||||
assert.NotEqualf(t, hash1, hash2, "hashes %q and %q are not unique", hash1, hash2)
|
||||
}
|
||||
|
||||
func BenchmarkHashPassword(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := hashing.HashPassword("t3stp4ssw0rd", hashing.DefaultArgon2Params)
|
||||
if err != nil {
|
||||
b.Errorf("failed to hash password #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
hash = "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk"
|
||||
)
|
||||
|
||||
func TestComparePasswordAndHash(t *testing.T) {
|
||||
match, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", hash)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, match)
|
||||
|
||||
match, err = hashing.ComparePasswordAndHash("wr0ngt3stp4ssw0rd", hash)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, match)
|
||||
}
|
||||
|
||||
func BenchmarkComparePasswordAndHash(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", hash)
|
||||
if err != nil {
|
||||
b.Errorf("failed to compare password and hash #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkCompareWrongPasswordAndHash(b *testing.B) {
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, err := hashing.ComparePasswordAndHash("wr0ngt3stp4ssw0rd", hash)
|
||||
if err != nil {
|
||||
b.Errorf("failed to compare wrong password and hash #%d: %v", i, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComparePasswordAndInvalidHash(t *testing.T) {
|
||||
_, err := hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2i$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=20$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrIncompatibleArgon2Version, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=a$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrIncompatibleArgon2Version, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=a,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=a,p=4$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=a$c8FqPHMT83tyxE2v0xDAFw$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$ä$s2qmbRoRRbfyLIVFUzRwzE7F8PLjchpLKaV7Wf7tHgk")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
|
||||
_, err = hashing.ComparePasswordAndHash("t3stp4ssw0rd", "$argon2id$v=19$m=65536,t=1,p=4$c8FqPHMT83tyxE2v0xDAFw$ä")
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, hashing.ErrInvalidArgon2Hash, err)
|
||||
}
|
||||
17
internal/util/hashing/util.go
Normal file
17
internal/util/hashing/util.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package hashing
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
func generateSalt(n uint32) ([]byte, error) {
|
||||
result := make([]byte, n)
|
||||
|
||||
_, err := rand.Read(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
374
internal/util/http.go
Normal file
374
internal/util/http.go
Normal file
@@ -0,0 +1,374 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
oerrors "github.com/go-openapi/errors"
|
||||
"github.com/go-openapi/runtime"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
const (
|
||||
HTTPHeaderCacheControl = "Cache-Control"
|
||||
)
|
||||
|
||||
// BindAndValidateBody binds the request, parsing **only** its body (depending on the `Content-Type` request header) and performs validation
|
||||
// as enforced by the Swagger schema associated with the provided type.
|
||||
//
|
||||
// Note: In contrast to BindAndValidate, this method does not restore the body after binding (it's considered consumed).
|
||||
// Thus use BindAndValidateBody only once per request!
|
||||
//
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail.
|
||||
func BindAndValidateBody(c echo.Context, v runtime.Validatable) error {
|
||||
binder, ok := c.Echo().Binder.(*echo.DefaultBinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder)
|
||||
}
|
||||
|
||||
if err := binder.BindBody(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind body: %w", err)
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
// BindAndValidatePathAndQueryParams binds the request, parsing **only** its path **and** query params and performs validation
|
||||
// as enforced by the Swagger schema associated with the provided type.
|
||||
//
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail.
|
||||
func BindAndValidatePathAndQueryParams(c echo.Context, v runtime.Validatable) error {
|
||||
binder, ok := c.Echo().Binder.(*echo.DefaultBinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder)
|
||||
}
|
||||
|
||||
if err := binder.BindPathParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind path params: %w", err)
|
||||
}
|
||||
|
||||
if err := binder.BindQueryParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind query params: %w", err)
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
// BindAndValidatePathParams binds the request, parsing **only** its path params and performs validation
|
||||
// as enforced by the Swagger schema associated with the provided type.
|
||||
//
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail.
|
||||
func BindAndValidatePathParams(c echo.Context, v runtime.Validatable) error {
|
||||
binder, ok := c.Echo().Binder.(*echo.DefaultBinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder)
|
||||
}
|
||||
|
||||
if err := binder.BindPathParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind path params: %w", err)
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
// BindAndValidateQueryParams binds the request, parsing **only** its query params and performs validation
|
||||
// as enforced by the Swagger schema associated with the provided type.
|
||||
//
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail.
|
||||
func BindAndValidateQueryParams(c echo.Context, v runtime.Validatable) error {
|
||||
binder, ok := c.Echo().Binder.(*echo.DefaultBinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder)
|
||||
}
|
||||
|
||||
if err := binder.BindQueryParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind query params: %w", err)
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
// BindAndValidate binds the request, parsing path+query+body and validating these structs.
|
||||
//
|
||||
// Deprecated: Use our dedicated BindAndValidate* mappers instead:
|
||||
//
|
||||
// BindAndValidateBody(c echo.Context, v runtime.Validatable) error // preferred
|
||||
// BindAndValidatePathAndQueryParams(c echo.Context, v runtime.Validatable) error // preferred
|
||||
// BindAndValidatePathParams(c echo.Context, v runtime.Validatable) error // rare usecases
|
||||
// BindAndValidateQueryParams(c echo.Context, v runtime.Validatable) error // rare usecases
|
||||
//
|
||||
// BindAndValidate works like Echo <v4.2.0. It was preferred to .Bind() everything (query, params, body) to a single struct
|
||||
// in one pass. Thus we included additional handling to allow multiple body rebindings (though copying while restoring),
|
||||
// as goswagger generated structs per endpoint are typically **separated** into one params struct (path and query) and one
|
||||
// body struct. Echo >=v4.2.0 DefaultBinder now supports binding query, path params and body to their **own** structs natively.
|
||||
// Thus, you areencouraged to use our new dedicated BindAndValidate* mappers, which are relevant for the structs goswagger
|
||||
// autogenerates for you.
|
||||
//
|
||||
// Original: Parses body (depending on the `Content-Type` request header) and performs payload validation as enforced by
|
||||
// the Swagger schema associated with the provided type. In addition to binding the body, BindAndValidate also assigns query
|
||||
// and URL parameters (if any) to a struct and perform validations on those.
|
||||
//
|
||||
// Providing more than one struct allows for binding payload and parameters simultaneously since echo and goswagger expect data
|
||||
// to be structured differently. If you do not require parsing of both body and params, additional structs can be omitted.
|
||||
//
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should binding or validating of any model fail.
|
||||
func BindAndValidate(c echo.Context, v runtime.Validatable, validatables ...runtime.Validatable) error {
|
||||
// TODO error handling for all occurrences of Bind() due to JSON unmarshal type mismatches
|
||||
if len(validatables) == 0 {
|
||||
if err := defaultEchoBindAll(c, v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
var reqBody []byte
|
||||
var err error
|
||||
if c.Request().Body != nil {
|
||||
reqBody, err = io.ReadAll(c.Request().Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read request body: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = restoreBindAndValidate(c, reqBody, v); err != nil {
|
||||
return fmt.Errorf("failed to restore bind and validate: %w", err)
|
||||
}
|
||||
|
||||
for _, vv := range validatables {
|
||||
if err = restoreBindAndValidate(c, reqBody, vv); err != nil {
|
||||
return fmt.Errorf("failed to restore bind and validate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateAndReturn returns the provided data as a JSON response with the given HTTP status code after performing payload
|
||||
// validation as enforced by the Swagger schema associated with the provided type.
|
||||
// `v` must implement `github.com/go-openapi/runtime.Validatable` in order to perform validations, otherwise an internal server error is thrown.
|
||||
// Returns an error that can directly be returned from an echo handler and sent to the client should sending or validating fail.
|
||||
func ValidateAndReturn(c echo.Context, code int, v runtime.Validatable) error {
|
||||
if err := validatePayload(c, v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := c.JSON(code, v); err != nil {
|
||||
return fmt.Errorf("failed to return JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseFileUpload(c echo.Context, formNameFile string, allowedMIMETypes []string) (*multipart.FileHeader, multipart.File, *mimetype.MIME, error) {
|
||||
log := LogFromEchoContext(c)
|
||||
|
||||
fileHeader, err := c.FormFile(formNameFile)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Msg("Failed to get form file")
|
||||
return nil, nil, nil, fmt.Errorf("failed to get form file: %w", err)
|
||||
}
|
||||
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to open uploaded file")
|
||||
return nil, nil, nil, fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
|
||||
if fileHeader.Size < 1 {
|
||||
log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("File size can't be 0")
|
||||
return nil, nil, nil, httperrors.ErrBadRequestZeroFileSize
|
||||
}
|
||||
|
||||
mime, err := mimetype.DetectReader(file)
|
||||
if err != nil {
|
||||
log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to detect MIME type of uploaded file")
|
||||
file.Close()
|
||||
return nil, nil, nil, fmt.Errorf("failed to detect MIME type of uploaded file: %w", err)
|
||||
}
|
||||
|
||||
// ! Important: we *MUST* reset the reader back to 0, since `minetype.DetectReader` reads the beginning of the
|
||||
// ! file in order to detect it's MIME type. Continuing to use the reader without resetting it results in a
|
||||
// ! corrupted file unable to be processed or opened otherwise.
|
||||
if _, err = file.Seek(0, io.SeekStart); err != nil {
|
||||
log.Debug().Err(err).Str("filename", fileHeader.Filename).Int64("fileSize", fileHeader.Size).Msg("Failed to reset reader of uploaded file to start")
|
||||
file.Close()
|
||||
return nil, nil, nil, fmt.Errorf("failed to reset reader of uploaded file to start: %w", err)
|
||||
}
|
||||
|
||||
allowed := false
|
||||
for _, allowedType := range allowedMIMETypes {
|
||||
if mime.Is(allowedType) {
|
||||
log.Debug().
|
||||
Str("mimeType", mime.String()).
|
||||
Str("mimeTypeFileExtension", mime.Extension()).
|
||||
Str("filename", fileHeader.Filename).
|
||||
Int64("fileSize", fileHeader.Size).
|
||||
Str("allowedMIMEType", allowedType).
|
||||
Msg("MIME type of uploaded file is allowed, processing")
|
||||
|
||||
allowed = true
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
log.Debug().
|
||||
Str("mimeType", mime.String()).
|
||||
Str("mimeTypeFileExtension", mime.Extension()).
|
||||
Str("filename", fileHeader.Filename).
|
||||
Int64("fileSize", fileHeader.Size).
|
||||
Msg("MIME type of uploaded file is not allowed, rejecting")
|
||||
file.Close()
|
||||
|
||||
return nil, nil, nil, echo.ErrUnsupportedMediaType
|
||||
}
|
||||
|
||||
return fileHeader, file, mime, nil
|
||||
}
|
||||
|
||||
func StreamFile(c echo.Context, code int, mediaType string, fileName string, r io.ReadCloser) error {
|
||||
formattedMediaType := mime.FormatMediaType("attachment",
|
||||
map[string]string{
|
||||
"filename": fileName,
|
||||
},
|
||||
)
|
||||
|
||||
c.Response().Header().Set(echo.HeaderContentDisposition, formattedMediaType)
|
||||
SetOrAppendHeader(c.Response().Header(), echo.HeaderAccessControlExposeHeaders, echo.HeaderContentDisposition)
|
||||
|
||||
defer r.Close()
|
||||
|
||||
if err := c.Stream(code, mediaType, r); err != nil {
|
||||
return fmt.Errorf("failed to stream file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetOrAppendHeader(header http.Header, key string, values ...string) {
|
||||
headerSet := header.Get(key)
|
||||
headerValue := strings.Join(values, ", ")
|
||||
if headerSet == "" {
|
||||
header.Add(key, headerValue)
|
||||
} else {
|
||||
header.Set(key, strings.Join([]string{headerSet, headerValue}, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func restoreBindAndValidate(c echo.Context, reqBody []byte, v runtime.Validatable) error {
|
||||
if reqBody != nil {
|
||||
c.Request().Body = io.NopCloser(bytes.NewBuffer(reqBody))
|
||||
}
|
||||
|
||||
if err := defaultEchoBindAll(c, v); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return validatePayload(c, v)
|
||||
}
|
||||
|
||||
func validatePayload(c echo.Context, v runtime.Validatable) error {
|
||||
if err := v.Validate(strfmt.Default); err != nil {
|
||||
var compositeError *oerrors.CompositeError
|
||||
if errors.As(err, &compositeError) {
|
||||
LogFromEchoContext(c).Debug().Errs("validation_errors", compositeError.Errors).Msg("Payload did match schema, returning HTTP validation error")
|
||||
|
||||
valErrs := formatValidationErrors(c.Request().Context(), compositeError)
|
||||
|
||||
return httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
}
|
||||
|
||||
var validationError *oerrors.Validation
|
||||
if errors.As(err, &validationError) {
|
||||
LogFromEchoContext(c).Debug().AnErr("validation_error", validationError).Msg("Payload did match schema, returning HTTP validation error")
|
||||
|
||||
valErrs := []*types.HTTPValidationErrorDetail{
|
||||
{
|
||||
Key: &validationError.Name,
|
||||
In: &validationError.In,
|
||||
Error: swag.String(validationError.Error()),
|
||||
},
|
||||
}
|
||||
|
||||
return httperrors.NewHTTPValidationError(http.StatusBadRequest, types.PublicHTTPErrorTypeGeneric, http.StatusText(http.StatusBadRequest), valErrs)
|
||||
}
|
||||
|
||||
LogFromEchoContext(c).Error().Err(err).Msg("Failed to validate payload, returning generic HTTP error")
|
||||
|
||||
return fmt.Errorf("failed to validate payload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bind it all
|
||||
// Restores echo query binding pre 4.2.0 handling
|
||||
// Newer echo versions no longer automatically bind query params to tagged :query struct-fields unless its a GET or DELETE request
|
||||
// Workaround, depends on the internal echo.DefaultBinder methods.
|
||||
//
|
||||
// TODO: Eventually move to a customly implemented Binder.
|
||||
// Hopefully BindPathParams, BindQueryParams and BindBody stay provided in the future.
|
||||
//
|
||||
// This upstream security fix does not directly affect us, as our goswagger generated params/query structs
|
||||
// and body structs are separated from each other and cannot collide/overwrite props.
|
||||
// https://github.com/labstack/echo/commit/4d626c210d3946814a30d545adf9b8f2296686a7#diff-aade326d3512b5a2ada6faa791ddec468f2a0adedb352339c9e314e74c8949d2
|
||||
func defaultEchoBindAll(c echo.Context, v runtime.Validatable) error {
|
||||
binder, ok := c.Echo().Binder.(*echo.DefaultBinder)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to get binder as *echo.DefaultBinder, got %T", c.Echo().Binder)
|
||||
}
|
||||
|
||||
if err := binder.BindPathParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind path params: %w", err)
|
||||
}
|
||||
if err := binder.BindQueryParams(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind query params: %w", err)
|
||||
}
|
||||
|
||||
if err := binder.BindBody(c, v); err != nil {
|
||||
return fmt.Errorf("failed to bind body: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatValidationErrors(ctx context.Context, compositeError *oerrors.CompositeError) []*types.HTTPValidationErrorDetail {
|
||||
valErrs := make([]*types.HTTPValidationErrorDetail, 0, len(compositeError.Errors))
|
||||
for _, err := range compositeError.Errors {
|
||||
var compositeError *oerrors.CompositeError
|
||||
if errors.As(err, &compositeError) {
|
||||
valErrs = append(valErrs, formatValidationErrors(ctx, compositeError)...)
|
||||
continue
|
||||
}
|
||||
|
||||
var validationError *oerrors.Validation
|
||||
if errors.As(err, &validationError) {
|
||||
valErrs = append(valErrs, &types.HTTPValidationErrorDetail{
|
||||
Key: &validationError.Name,
|
||||
In: &validationError.In,
|
||||
Error: swag.String(validationError.Error()),
|
||||
})
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
LogFromContext(ctx).Warn().Err(err).Str("err_type", fmt.Sprintf("%T", err)).Msg("Received unknown error type while validating payload, skipping")
|
||||
}
|
||||
|
||||
return valErrs
|
||||
}
|
||||
233
internal/util/http_test.go
Normal file
233
internal/util/http_test.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/api"
|
||||
"allaboutapps.dev/aw/go-starter/internal/api/httperrors"
|
||||
"allaboutapps.dev/aw/go-starter/internal/test"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types"
|
||||
"allaboutapps.dev/aw/go-starter/internal/types/auth"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/go-openapi/strfmt"
|
||||
"github.com/go-openapi/strfmt/conv"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestBindAndValidateSuccess(t *testing.T) {
|
||||
e := echo.New()
|
||||
//nolint:gosec
|
||||
testToken := "a546daf5-c845-46a7-8fa6-3d94ae7e1424"
|
||||
testResponse := &types.PostLoginResponse{
|
||||
AccessToken: conv.UUID4(strfmt.UUID4("afbcbc30-4794-48bd-93f1-08373a031fe3")),
|
||||
RefreshToken: conv.UUID4(strfmt.UUID4("1dd1228c-fa9a-4755-b995-30e24dd6247d")),
|
||||
ExpiresIn: swag.Int64(3600),
|
||||
TokenType: swag.String("Bearer"),
|
||||
}
|
||||
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
testParam1 := auth.NewGetUserInfoRouteParams()
|
||||
testParam2 := auth.NewPostForgotPasswordRouteParams()
|
||||
var body types.PostRefreshPayload
|
||||
|
||||
err := util.BindAndValidate(c, &body, &testParam1, &testParam2)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, body)
|
||||
assert.Equal(t, strfmt.UUID4(testToken), *body.RefreshToken)
|
||||
|
||||
return util.ValidateAndReturn(c, 200, testResponse)
|
||||
})
|
||||
testBody := test.GenericPayload{
|
||||
"refresh_token": testToken,
|
||||
}
|
||||
|
||||
s := &api.Server{
|
||||
Echo: e,
|
||||
}
|
||||
|
||||
res := test.PerformRequest(t, s, "POST", "/?test=true", testBody, nil)
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
var response types.PostLoginResponse
|
||||
test.ParseResponseAndValidate(t, res, &response)
|
||||
|
||||
assert.Equal(t, *testResponse, response)
|
||||
}
|
||||
|
||||
func TestBindAndValidateBadRequest(t *testing.T) {
|
||||
e := echo.New()
|
||||
testToken := "foo"
|
||||
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
var body types.PostRefreshPayload
|
||||
|
||||
err := util.BindAndValidateBody(c, &body)
|
||||
require.Error(t, err)
|
||||
|
||||
return nil
|
||||
})
|
||||
testBody := test.GenericPayload{
|
||||
"refresh_token": testToken,
|
||||
}
|
||||
|
||||
s := &api.Server{
|
||||
Echo: e,
|
||||
}
|
||||
|
||||
_ = test.PerformRequest(t, s, "POST", "/?test=true", testBody, nil)
|
||||
}
|
||||
|
||||
func TestParseFileUplaod(t *testing.T) {
|
||||
originalDocumentPath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
|
||||
body, contentType := prepareFileUpload(t, originalDocumentPath)
|
||||
|
||||
e := echo.New()
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"image/jpeg"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mime.Is("image/jpeg"))
|
||||
assert.NotEmpty(t, fh)
|
||||
assert.NotEmpty(t, file)
|
||||
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
s := &api.Server{
|
||||
Echo: e,
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderContentType, contentType)
|
||||
|
||||
res := test.PerformRequestWithRawBody(t, s, "POST", "/", body, headers, nil)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, res.Result().StatusCode)
|
||||
}
|
||||
|
||||
func TestParseFileUplaodUnsupported(t *testing.T) {
|
||||
originalDocumentPath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
|
||||
body, contentType := prepareFileUpload(t, originalDocumentPath)
|
||||
|
||||
e := echo.New()
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"image/png"})
|
||||
assert.Nil(t, fh)
|
||||
assert.Nil(t, file)
|
||||
assert.Nil(t, mime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
s := &api.Server{
|
||||
Echo: e,
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderContentType, contentType)
|
||||
|
||||
res := test.PerformRequestWithRawBody(t, s, "POST", "/", body, headers, nil)
|
||||
require.Equal(t, http.StatusUnsupportedMediaType, res.Result().StatusCode)
|
||||
}
|
||||
|
||||
func TestParseFileUplaodEmpty(t *testing.T) {
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
_, err := writer.CreateFormFile("file", filepath.Base("example.txt"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writer.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
e := echo.New()
|
||||
e.POST("/", func(c echo.Context) error {
|
||||
fh, file, mime, err := util.ParseFileUpload(c, "file", []string{"text/plain"})
|
||||
assert.Nil(t, fh)
|
||||
assert.Nil(t, file)
|
||||
assert.Nil(t, mime)
|
||||
assert.Equal(t, httperrors.ErrBadRequestZeroFileSize, err)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse file upload: %w", err)
|
||||
}
|
||||
|
||||
return c.NoContent(204)
|
||||
})
|
||||
|
||||
s := &api.Server{
|
||||
Echo: e,
|
||||
}
|
||||
|
||||
headers := http.Header{}
|
||||
headers.Set(echo.HeaderContentType, writer.FormDataContentType())
|
||||
|
||||
test.PerformRequestWithRawBody(t, s, "POST", "/", &body, headers, nil)
|
||||
}
|
||||
|
||||
func prepareFileUpload(t *testing.T, filePath string) (*bytes.Buffer, string) {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
writer := multipart.NewWriter(&body)
|
||||
|
||||
src, err := os.Open(filePath)
|
||||
require.NoError(t, err)
|
||||
defer src.Close()
|
||||
|
||||
dst, err := writer.CreateFormFile("file", filepath.Base(src.Name()))
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = io.Copy(dst, src)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = writer.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
return &body, writer.FormDataContentType()
|
||||
}
|
||||
|
||||
func TestStreamFile(t *testing.T) {
|
||||
filename := "file_with_special_characters_🎉_س_.vcf"
|
||||
|
||||
e := echo.New()
|
||||
e.GET("/files", func(c echo.Context) error {
|
||||
path := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
|
||||
|
||||
mediaType, err := mimetype.DetectFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
file, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
return util.StreamFile(c, http.StatusOK, mediaType.String(), filename, file)
|
||||
})
|
||||
|
||||
s := &api.Server{Echo: e}
|
||||
|
||||
res := test.PerformRequest(t, s, "GET", "/files", nil, nil)
|
||||
require.Equal(t, http.StatusOK, res.Result().StatusCode)
|
||||
|
||||
mediaType, params, err := mime.ParseMediaType(res.Header().Get(echo.HeaderContentDisposition))
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "attachment", mediaType)
|
||||
assert.Equal(t, filename, params["filename"])
|
||||
|
||||
contentType := res.Header().Get(echo.HeaderContentType)
|
||||
assert.Equal(t, "image/jpeg", contentType)
|
||||
}
|
||||
32
internal/util/int.go
Normal file
32
internal/util/int.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"github.com/go-openapi/swag"
|
||||
)
|
||||
|
||||
func IntPtrToInt64Ptr(num *int) *int64 {
|
||||
if num == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Int64(int64(*num))
|
||||
}
|
||||
|
||||
func Int64PtrToIntPtr(num *int64) *int {
|
||||
if num == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Int(int(*num))
|
||||
}
|
||||
|
||||
func IntToInt32Ptr(num int) *int32 {
|
||||
if num > math.MaxInt32 || num < math.MinInt32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Int32(int32(num))
|
||||
}
|
||||
27
internal/util/int_test.go
Normal file
27
internal/util/int_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTypeConversions(t *testing.T) {
|
||||
i := 19
|
||||
if i > math.MaxInt32 || i < math.MinInt32 {
|
||||
t.Error("Int value is out of range")
|
||||
}
|
||||
|
||||
i64 := int64(i)
|
||||
i32 := int32(i)
|
||||
res := util.IntPtrToInt64Ptr(&i)
|
||||
assert.Equal(t, &i64, res)
|
||||
|
||||
res2 := util.Int64PtrToIntPtr(&i64)
|
||||
assert.Equal(t, &i, res2)
|
||||
|
||||
res3 := util.IntToInt32Ptr(i)
|
||||
assert.Equal(t, &i32, res3)
|
||||
}
|
||||
23
internal/util/lang.go
Normal file
23
internal/util/lang.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"golang.org/x/text/collate"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
// SortCollateStringSlice is used to sort a slice of strings if the language specific order of caracters is
|
||||
// important for the order of the string.
|
||||
// ! The slice passed will be changed.
|
||||
func SortCollateStringSlice(slice []string, lang language.Tag, options ...collate.Option) {
|
||||
if len(options) == 0 {
|
||||
options = []collate.Option{collate.IgnoreCase, collate.IgnoreWidth}
|
||||
}
|
||||
coll := collate.New(lang, options...)
|
||||
|
||||
sort.Slice(slice, func(i int, j int) bool {
|
||||
return coll.CompareString(slice[i], slice[j]) < 0
|
||||
})
|
||||
}
|
||||
34
internal/util/lang_test.go
Normal file
34
internal/util/lang_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/text/collate"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func TestSortCollateStringGerman(t *testing.T) {
|
||||
slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"}
|
||||
util.SortCollateStringSlice(slice, language.German)
|
||||
|
||||
expected := []string{"a", "ä", "e", "o", "ö", "u", "ü"}
|
||||
assert.Equal(t, expected, slice)
|
||||
}
|
||||
|
||||
func TestSortCollateStringEnglish(t *testing.T) {
|
||||
slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"}
|
||||
util.SortCollateStringSlice(slice, language.English)
|
||||
|
||||
expected := []string{"a", "ä", "e", "o", "ö", "u", "ü"}
|
||||
assert.Equal(t, expected, slice)
|
||||
}
|
||||
|
||||
func TestSortCollateStringGermanAndOptions(t *testing.T) {
|
||||
slice := []string{"a", "ä", "e", "ö", "u", "ü", "o"}
|
||||
util.SortCollateStringSlice(slice, language.German, collate.IgnoreCase, collate.IgnoreWidth, collate.IgnoreDiacritics)
|
||||
|
||||
expected := []string{"a", "ä", "e", "ö", "o", "u", "ü"}
|
||||
assert.Equal(t, expected, slice)
|
||||
}
|
||||
44
internal/util/log.go
Normal file
44
internal/util/log.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// LogFromContext returns a request-specific zerolog instance using the provided context.
|
||||
// The returned logger will have the request ID as well as some other value predefined.
|
||||
// If no logger is associated with the context provided, the global zerolog instance
|
||||
// will be returned instead - this function will _always_ return a valid (enabled) logger.
|
||||
// Should you ever need to force a disabled logger for a context, use `util.DisableLogger(ctx, true)`
|
||||
// and pass the context returned to other code/`LogFromContext`.
|
||||
func LogFromContext(ctx context.Context) *zerolog.Logger {
|
||||
logger := log.Ctx(ctx)
|
||||
if logger.GetLevel() == zerolog.Disabled {
|
||||
if ShouldDisableLogger(ctx) {
|
||||
return logger
|
||||
}
|
||||
logger = &log.Logger
|
||||
}
|
||||
|
||||
return logger
|
||||
}
|
||||
|
||||
// LogFromEchoContext returns a request-specific zerolog instance using the echo.Context of the request.
|
||||
// The returned logger will have the request ID as well as some other value predefined.
|
||||
func LogFromEchoContext(c echo.Context) *zerolog.Logger {
|
||||
return LogFromContext(c.Request().Context())
|
||||
}
|
||||
|
||||
func LogLevelFromString(s string) zerolog.Level {
|
||||
level, err := zerolog.ParseLevel(s)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msgf("Failed to parse log level, defaulting to %s", zerolog.DebugLevel)
|
||||
return zerolog.DebugLevel
|
||||
}
|
||||
|
||||
return level
|
||||
}
|
||||
20
internal/util/log_test.go
Normal file
20
internal/util/log_test.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestLogLevelFromString(t *testing.T) {
|
||||
res := util.LogLevelFromString("panic")
|
||||
assert.Equal(t, zerolog.PanicLevel, res)
|
||||
|
||||
res = util.LogLevelFromString("warn")
|
||||
assert.Equal(t, zerolog.WarnLevel, res)
|
||||
|
||||
res = util.LogLevelFromString("foo")
|
||||
assert.Equal(t, zerolog.DebugLevel, res)
|
||||
}
|
||||
12
internal/util/map.go
Normal file
12
internal/util/map.go
Normal file
@@ -0,0 +1,12 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
func MergeStringMap(base map[string]string, toMerge map[string]string) map[string]string {
|
||||
for k, v := range toMerge {
|
||||
if _, ok := base[k]; !ok {
|
||||
base[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return base
|
||||
}
|
||||
41
internal/util/map_test.go
Normal file
41
internal/util/map_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestMergeStringMap(t *testing.T) {
|
||||
baseMap := map[string]string{
|
||||
"A": "a",
|
||||
"B": "b",
|
||||
"C": "c",
|
||||
}
|
||||
|
||||
toMerge := map[string]string{
|
||||
"C": "1",
|
||||
"D": "2",
|
||||
}
|
||||
|
||||
expected := map[string]string{
|
||||
"A": "a",
|
||||
"B": "b",
|
||||
"C": "c",
|
||||
"D": "2",
|
||||
}
|
||||
|
||||
res := util.MergeStringMap(baseMap, toMerge)
|
||||
assert.Equal(t, expected, res)
|
||||
|
||||
expected = map[string]string{
|
||||
"C": "1",
|
||||
"D": "2",
|
||||
"A": "a",
|
||||
"B": "b",
|
||||
}
|
||||
|
||||
res = util.MergeStringMap(toMerge, baseMap)
|
||||
assert.Equal(t, expected, res)
|
||||
}
|
||||
33
internal/util/mime/mime.go
Normal file
33
internal/util/mime/mime.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package mime
|
||||
|
||||
import "github.com/gabriel-vasile/mimetype"
|
||||
|
||||
var _ MIME = (*mimetype.MIME)(nil)
|
||||
|
||||
// MIME interface enables to use either *mimetype.MIME or KnownMIME as mimetype.
|
||||
type MIME interface {
|
||||
String() string
|
||||
Extension() string
|
||||
Is(expectedMIME string) bool
|
||||
}
|
||||
|
||||
// KnownMIME implements the MIME interface to be able to pass a *mimetype.MIME
|
||||
// compatible value if the mimetype is already known so mimetype detection is not
|
||||
// needed. It is therefore possible to skip mimetype detection if the mimetype is known
|
||||
// or it is not possible to use a readSeeker but a mimetype is required.
|
||||
type KnownMIME struct {
|
||||
MimeType string
|
||||
FileExtension string
|
||||
}
|
||||
|
||||
func (m *KnownMIME) String() string {
|
||||
return m.MimeType
|
||||
}
|
||||
|
||||
func (m *KnownMIME) Extension() string {
|
||||
return m.FileExtension
|
||||
}
|
||||
|
||||
func (m *KnownMIME) Is(expectedMIME string) bool {
|
||||
return expectedMIME == m.MimeType
|
||||
}
|
||||
30
internal/util/mime/mime_test.go
Normal file
30
internal/util/mime/mime_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package mime_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/mime"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestKnownMIME(t *testing.T) {
|
||||
filePath := filepath.Join(util.GetProjectRootDir(), "test", "testdata", "example.jpg")
|
||||
|
||||
var detectedMIME mime.MIME
|
||||
var err error
|
||||
detectedMIME, err = mimetype.DetectFile(filePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
var knownMIME mime.MIME = &mime.KnownMIME{
|
||||
MimeType: "image/jpeg",
|
||||
FileExtension: ".jpg",
|
||||
}
|
||||
|
||||
assert.Equal(t, detectedMIME.Extension(), knownMIME.Extension())
|
||||
assert.Equal(t, detectedMIME.String(), knownMIME.String())
|
||||
assert.True(t, knownMIME.Is(detectedMIME.String()))
|
||||
}
|
||||
35
internal/util/oauth2/pkce.go
Normal file
35
internal/util/oauth2/pkce.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package oauth2
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultVerifierLength = 128
|
||||
)
|
||||
|
||||
func GetPKCECodeVerifier() (string, error) {
|
||||
// for details regarding possible characters in verifier, see:
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.1
|
||||
verifier, err := util.GenerateRandomString(defaultVerifierLength, []util.CharRange{util.CharRangeNumeric, util.CharRangeAlphaLowerCase, util.CharRangeAlphaUpperCase}, "-._~")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to generate random string: %w", err)
|
||||
}
|
||||
|
||||
return verifier, nil
|
||||
}
|
||||
|
||||
func GetPKCECodeChallengeS256(verifier string) string {
|
||||
// for details regarding transformation of verifier to challenge see:
|
||||
// https://tools.ietf.org/html/rfc7636#section-4.2
|
||||
// base64 encoding must be unpadded, URL encoding:
|
||||
// https://tools.ietf.org/html/rfc7636#page-17
|
||||
sum := sha256.Sum256([]byte(verifier))
|
||||
b64 := base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
|
||||
return b64
|
||||
}
|
||||
16
internal/util/oauth2/pkce_test.go
Normal file
16
internal/util/oauth2/pkce_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package oauth2_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util/oauth2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetPKCECodeChallengeS256(t *testing.T) {
|
||||
verifier := "U7MEZRmshzwIHRIGvF5iy6FLKgTtUHV0Vb0Hpczh6jJ_XZKcQIurow2LvsjG6hx2k57s9Pz8UmCZTvazosnniTM-z6EC.skJlQMGA~8ue3LMiOWdFYTfsLdX8GKol285"
|
||||
expected := "Jg697bAjhzV1upYvV9R04784OFNVRAZh2IjeFlMJ8bE"
|
||||
|
||||
challenge := oauth2.GetPKCECodeChallengeS256(verifier)
|
||||
assert.Equal(t, expected, challenge)
|
||||
}
|
||||
40
internal/util/path.go
Normal file
40
internal/util/path.go
Normal file
@@ -0,0 +1,40 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FileNameWithoutExtension returns the name of the file referenced by the
|
||||
// provided path without the file's extension.
|
||||
// The function accepts a full (local) file path as well, only the latest
|
||||
// element of the path will be considered as a name.
|
||||
// If the provided path is empty or consists entirely of separators, an
|
||||
// empty string will be returned.
|
||||
func FileNameWithoutExtension(path string) string {
|
||||
base := filepath.Base(path)
|
||||
if base == "." || base == "/" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(base, filepath.Ext(path))
|
||||
}
|
||||
|
||||
// FileNameAndExtension returns the name of the file referenced by the
|
||||
// provided path as well as its extension as separated strings.
|
||||
// The function accepts a full (local) file path as well, only the latest
|
||||
// element of the path will be considered as a name.
|
||||
// If the provided path is empty or consists entirely of separators,
|
||||
// empty strings will be returned.
|
||||
func FileNameAndExtension(path string) (string, string) {
|
||||
base := filepath.Base(path)
|
||||
if base == "." || base == "/" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
extension := filepath.Ext(path)
|
||||
fileName := strings.TrimSuffix(base, extension)
|
||||
|
||||
return fileName, extension
|
||||
}
|
||||
42
internal/util/path_test.go
Normal file
42
internal/util/path_test.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestGetFileNameWithoutExtension(t *testing.T) {
|
||||
assert.Equal(t, "example", util.FileNameWithoutExtension("/a/b/c/d/example.jpg"))
|
||||
assert.Equal(t, "example", util.FileNameWithoutExtension("example.jpg"))
|
||||
assert.Equal(t, "example_test-check", util.FileNameWithoutExtension("example_test-check.jpg"))
|
||||
assert.Equal(t, "example", util.FileNameWithoutExtension("example"))
|
||||
assert.Empty(t, util.FileNameWithoutExtension(""))
|
||||
assert.Empty(t, util.FileNameWithoutExtension("."))
|
||||
assert.Empty(t, util.FileNameWithoutExtension("///"))
|
||||
}
|
||||
|
||||
func TestFileNameAndExtension(t *testing.T) {
|
||||
name, ext := util.FileNameAndExtension("/a/b/c/d/example.jpg")
|
||||
assert.Equal(t, "example", name)
|
||||
assert.Equal(t, ".jpg", ext)
|
||||
name, ext = util.FileNameAndExtension("example.jpg")
|
||||
assert.Equal(t, "example", name)
|
||||
assert.Equal(t, ".jpg", ext)
|
||||
name, ext = util.FileNameAndExtension("example_test-check.jpg")
|
||||
assert.Equal(t, "example_test-check", name)
|
||||
assert.Equal(t, ".jpg", ext)
|
||||
name, ext = util.FileNameAndExtension("example")
|
||||
assert.Equal(t, "example", name)
|
||||
assert.Empty(t, ext)
|
||||
name, ext = util.FileNameAndExtension("")
|
||||
assert.Empty(t, name)
|
||||
assert.Empty(t, ext)
|
||||
name, ext = util.FileNameAndExtension(".")
|
||||
assert.Empty(t, name)
|
||||
assert.Empty(t, ext)
|
||||
name, ext = util.FileNameAndExtension("///")
|
||||
assert.Empty(t, name)
|
||||
assert.Empty(t, ext)
|
||||
}
|
||||
39
internal/util/slice.go
Normal file
39
internal/util/slice.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
// ContainsAllString checks whether the given string slice contains all strings provided.
|
||||
func ContainsAllString(slice []string, sub ...string) bool {
|
||||
contains := make(map[string]bool)
|
||||
for _, v := range sub {
|
||||
contains[v] = false
|
||||
}
|
||||
|
||||
for _, v := range slice {
|
||||
if _, ok := contains[v]; ok {
|
||||
contains[v] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, v := range contains {
|
||||
if !v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// UniqueString takes the string slice provided and returns a new slice with all duplicates removed.
|
||||
func UniqueString(slice []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
res := make([]string, 0)
|
||||
|
||||
for _, s := range slice {
|
||||
if _, ok := seen[s]; !ok {
|
||||
res = append(res, s)
|
||||
seen[s] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
31
internal/util/slice_test.go
Normal file
31
internal/util/slice_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestContainsAllString(t *testing.T) {
|
||||
test := []string{"a", "b", "d"}
|
||||
assert.True(t, util.ContainsAllString(test, "a"))
|
||||
assert.True(t, util.ContainsAllString(test, "b"))
|
||||
assert.False(t, util.ContainsAllString(test, "c"))
|
||||
assert.True(t, util.ContainsAllString(test, "d"))
|
||||
assert.True(t, util.ContainsAllString(test, "a", "b"))
|
||||
assert.True(t, util.ContainsAllString(test, "a", "d"))
|
||||
assert.True(t, util.ContainsAllString(test, "b", "d"))
|
||||
assert.False(t, util.ContainsAllString(test, "a", "c"))
|
||||
assert.False(t, util.ContainsAllString(test, "b", "c"))
|
||||
assert.False(t, util.ContainsAllString(test, "c", "d"))
|
||||
assert.True(t, util.ContainsAllString(test, "a", "b", "d"))
|
||||
assert.False(t, util.ContainsAllString(test, "a", "b", "c"))
|
||||
assert.False(t, util.ContainsAllString(test, "a", "b", "c", "d"))
|
||||
assert.True(t, util.ContainsAllString(test))
|
||||
}
|
||||
|
||||
func TestUniqueString(t *testing.T) {
|
||||
test := []string{"a", "b", "d", "d", "a", "d"}
|
||||
assert.Equal(t, []string{"a", "b", "d"}, util.UniqueString(test))
|
||||
}
|
||||
185
internal/util/string.go
Normal file
185
internal/util/string.go
Normal file
@@ -0,0 +1,185 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-openapi/swag"
|
||||
)
|
||||
|
||||
var (
|
||||
StringSpaceReplacer = regexp.MustCompile(`\s+`)
|
||||
)
|
||||
|
||||
// GenerateRandomBytes returns n random bytes securely generated using the system's default CSPRNG.
|
||||
//
|
||||
// An error will be returned if reading from the secure random number generator fails, at which point
|
||||
// the returned result should be discarded and not used any further.
|
||||
func GenerateRandomBytes(n int) ([]byte, error) {
|
||||
result := make([]byte, n)
|
||||
|
||||
_, err := rand.Read(result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GenerateRandomBase64String returns a string with n random bytes securely generated using the system's
|
||||
// default CSPRNG in base64 encoding. The resulting string might not be of length n as the encoding for
|
||||
// the raw bytes generated may vary.
|
||||
//
|
||||
// An error will be returned if reading from the secure random number generator fails, at which point
|
||||
// the returned result should be discarded and not used any further.
|
||||
func GenerateRandomBase64String(n int) (string, error) {
|
||||
b, err := GenerateRandomBytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return base64.StdEncoding.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// GenerateRandomHexString returns a string with n random bytes securely generated using the system's
|
||||
// default CSPRNG in hexadecimal encoding. The resulting string might not be of length n as the encoding
|
||||
// for the raw bytes generated may vary.
|
||||
//
|
||||
// An error will be returned if reading from the secure random number generator fails, at which point
|
||||
// the returned result should be discarded and not used any further.
|
||||
func GenerateRandomHexString(n int) (string, error) {
|
||||
b, err := GenerateRandomBytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
type CharRange int
|
||||
|
||||
const (
|
||||
CharRangeNumeric CharRange = iota
|
||||
CharRangeAlphaLowerCase
|
||||
CharRangeAlphaUpperCase
|
||||
)
|
||||
|
||||
// GenerateRandomString returns a string with n random bytes securely generated using the system's
|
||||
// default CSPRNG. The characters within the generated string will either be part of one ore more supplied
|
||||
// range of characters, or based on characters in the extra string supplied.
|
||||
//
|
||||
// An error will be returned if reading from the secure random number generator fails, at which point
|
||||
// the returned result should be discarded and not used any further.
|
||||
func GenerateRandomString(n int, ranges []CharRange, extra string) (string, error) {
|
||||
var str strings.Builder
|
||||
|
||||
if len(ranges) == 0 && len(extra) == 0 {
|
||||
return "", errors.New("random string can only be created if set of characters or extra string characters supplied")
|
||||
}
|
||||
|
||||
validateFn := func(elem byte) bool {
|
||||
// IndexByte(string, byte) is basically Contains(string, string) without casting
|
||||
if strings.IndexByte(extra, elem) >= 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
for _, r := range ranges {
|
||||
switch r {
|
||||
case CharRangeNumeric:
|
||||
if elem >= '0' && elem <= '9' {
|
||||
return true
|
||||
}
|
||||
case CharRangeAlphaLowerCase:
|
||||
if elem >= 'a' && elem <= 'z' {
|
||||
return true
|
||||
}
|
||||
case CharRangeAlphaUpperCase:
|
||||
if elem >= 'A' && elem <= 'Z' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
for str.Len() < n {
|
||||
buf, err := GenerateRandomBytes(n)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, b := range buf {
|
||||
if validateFn(b) {
|
||||
str.WriteByte(b)
|
||||
}
|
||||
if str.Len() >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return str.String(), nil
|
||||
}
|
||||
|
||||
// Lowercases a string and trims whitespace from the beginning and end of the string
|
||||
func ToUsernameFormat(s string) string {
|
||||
return strings.TrimSpace(strings.ToLower(s))
|
||||
}
|
||||
|
||||
// NonEmptyOrNil returns a pointer to passed string if it is not empty. Passing empty strings returns nil instead.
|
||||
func NonEmptyOrNil(s string) *string {
|
||||
if len(s) > 0 {
|
||||
return swag.String(s)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyIfNil returns an empty string if the passed pointer is nil. Passing a pointer to a string will return the value of the string.
|
||||
func EmptyIfNil(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *s
|
||||
}
|
||||
|
||||
// ContainsAll returns true if a string (str) contains all substrings (sub).
|
||||
func ContainsAll(str string, subs ...string) bool {
|
||||
subLen := len(subs)
|
||||
contains := make([]bool, subLen)
|
||||
indices := make([]int, subLen)
|
||||
substrings := make([][]rune, subLen)
|
||||
for i, substring := range subs {
|
||||
substrings[i] = []rune(substring)
|
||||
}
|
||||
|
||||
for _, marked := range str {
|
||||
for i, sub := range substrings {
|
||||
if len(sub) == 0 {
|
||||
contains[i] = true
|
||||
}
|
||||
if !contains[i] && marked == sub[indices[i]] {
|
||||
indices[i]++
|
||||
if indices[i] >= len(sub) {
|
||||
contains[i] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range contains {
|
||||
if !c {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
82
internal/util/string_test.go
Normal file
82
internal/util/string_test.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateRandom(t *testing.T) {
|
||||
res, err := util.GenerateRandomBytes(13)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res, 13)
|
||||
|
||||
randString, err := util.GenerateRandomBase64String(17)
|
||||
require.NoError(t, err)
|
||||
res, err = base64.StdEncoding.DecodeString(randString)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res, 17)
|
||||
|
||||
randString, err = util.GenerateRandomHexString(19)
|
||||
require.NoError(t, err)
|
||||
res, err = hex.DecodeString(randString)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, res, 19)
|
||||
|
||||
randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeAlphaLowerCase}, "/%$")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, randString, 19)
|
||||
for _, r := range randString {
|
||||
assert.True(t, (r >= 'a' && r <= 'z') || r == '/' || r == '%' || r == '$')
|
||||
}
|
||||
|
||||
randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeAlphaUpperCase}, "^\"")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, randString, 19)
|
||||
for _, r := range randString {
|
||||
assert.True(t, (r >= 'A' && r <= 'Z') || r == '^' || r == '"')
|
||||
}
|
||||
|
||||
randString, err = util.GenerateRandomString(19, []util.CharRange{util.CharRangeNumeric}, "")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, randString, 19)
|
||||
for _, r := range randString {
|
||||
assert.True(t, (r >= '0' && r <= '9'))
|
||||
}
|
||||
|
||||
_, err = util.GenerateRandomString(1, nil, "")
|
||||
require.Error(t, err)
|
||||
|
||||
randString, err = util.GenerateRandomString(8, nil, "a")
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, randString, 8)
|
||||
assert.Equal(t, "aaaaaaaa", randString)
|
||||
}
|
||||
|
||||
func TestNonEmptyOrNil(t *testing.T) {
|
||||
assert.Equal(t, "test", *util.NonEmptyOrNil("test"))
|
||||
assert.Equal(t, (*string)(nil), util.NonEmptyOrNil(""))
|
||||
}
|
||||
|
||||
func TestContainsAll(t *testing.T) {
|
||||
assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "dolor"))
|
||||
assert.False(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", "dolorx"))
|
||||
|
||||
assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", ".", "sit", "elit", "ipsum", "Lorem ipsum"))
|
||||
assert.False(t, util.ContainsAll("Lorem ipsum dolor sit amet, consectetur adipiscing elit.", ".", "sit", "elit", "ipsum", " Lorem"))
|
||||
|
||||
assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, ÄÜiö consectetur adipiscing elit.", "ÄÜiö c"))
|
||||
|
||||
assert.False(t, util.ContainsAll("", "ÄÜiö c"))
|
||||
assert.True(t, util.ContainsAll("Lorem ipsum dolor sit amet, ÄÜiö consectetur adipiscing elit.", ""))
|
||||
}
|
||||
|
||||
func TestEmptyIfNil(t *testing.T) {
|
||||
s := "Lorem ipsum"
|
||||
assert.Equal(t, s, util.EmptyIfNil(&s))
|
||||
assert.Empty(t, util.EmptyIfNil(nil))
|
||||
}
|
||||
107
internal/util/struct.go
Normal file
107
internal/util/struct.go
Normal file
@@ -0,0 +1,107 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
)
|
||||
|
||||
// GetFieldsImplementing returns all fields of a struct implementing a certain interface.
|
||||
// Returned fields are pointers to a type or interface objects.
|
||||
//
|
||||
// Parameter structPtr must be a pointer to a struct.
|
||||
// Parameter interfaceObject must be given as a pointer to an interface,
|
||||
// for example (*Insertable)(nil), where Insertable is an interface name.
|
||||
func GetFieldsImplementing[T any](structPtr interface{}, interfaceObject *T) ([]T, error) {
|
||||
// Verify if structPtr is a pointer to a struct
|
||||
inputParamStructType := reflect.TypeOf(structPtr)
|
||||
if inputParamStructType == nil ||
|
||||
inputParamStructType.Kind() != reflect.Ptr ||
|
||||
inputParamStructType.Elem().Kind() != reflect.Struct {
|
||||
return nil, errors.New("invalid input structPtr param: should be a pointer to a struct")
|
||||
}
|
||||
|
||||
inputParamIfcType := reflect.TypeOf(interfaceObject)
|
||||
// Verify if interfaceObject is a pointer to an interface
|
||||
if inputParamIfcType == nil ||
|
||||
inputParamIfcType.Kind() != reflect.Ptr ||
|
||||
inputParamIfcType.Elem().Kind() != reflect.Interface {
|
||||
return nil, errors.New("invalid input interfaceObject param: should be a pointer to an interface")
|
||||
}
|
||||
|
||||
// We need the type, not the pointer to it.
|
||||
// By using Elem() we can get the value pointed by the pointer.
|
||||
interfaceType := inputParamIfcType.Elem()
|
||||
structType := inputParamStructType.Elem()
|
||||
|
||||
structValue := reflect.ValueOf(structPtr).Elem()
|
||||
|
||||
retFields := make([]T, 0)
|
||||
|
||||
// Getting the VisibleFields returns all public fields in the struct
|
||||
for i, field := range reflect.VisibleFields(structType) {
|
||||
// Check if the field can be exported.
|
||||
// Interface() can be called only on exportable fields.
|
||||
if !field.IsExported() {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldValue := structValue.Field(i)
|
||||
|
||||
// Depending on the field type, different checks apply.
|
||||
switch field.Type.Kind() {
|
||||
case reflect.Pointer:
|
||||
// Let's check if it implements the interface.
|
||||
if field.Type.Implements(interfaceType) {
|
||||
// Great, we can add it to the return slice
|
||||
//nolint:forcetypeassert
|
||||
retFields = append(retFields, fieldValue.Interface().(T))
|
||||
}
|
||||
|
||||
case reflect.Interface:
|
||||
// If it's an interface, make sure it's not nil.
|
||||
if fieldValue.IsNil() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Now we can check if it's the same interface.
|
||||
if field.Type.Implements(interfaceType) {
|
||||
// Great, we can add it to the return slice
|
||||
//nolint:forcetypeassert
|
||||
retFields = append(retFields, fieldValue.Interface().(T))
|
||||
}
|
||||
|
||||
default:
|
||||
// We can skip any other cases.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return retFields, nil
|
||||
}
|
||||
|
||||
// IsStructInitialized checks if all the struct fields are initialized (not zero).
|
||||
// Members of the struct such as empty strings or numbers set to zero are interpreted as a zero value!
|
||||
// Parameter structPtr needs to be a pointer to a struct.
|
||||
func IsStructInitialized(structPtr interface{}) error {
|
||||
inputType := reflect.TypeOf(structPtr)
|
||||
if inputType == nil ||
|
||||
inputType.Kind() != reflect.Pointer ||
|
||||
inputType.Elem().Kind() != reflect.Struct {
|
||||
return errors.New("invalid input structPtr param: should be a pointer to a struct")
|
||||
}
|
||||
|
||||
// we want to access values of the struct, not value of the pointer, therefore we use Elem()
|
||||
structVal := reflect.ValueOf(structPtr).Elem()
|
||||
structType := inputType.Elem()
|
||||
|
||||
var err error
|
||||
for i := 0; i < structVal.NumField(); i++ {
|
||||
field := structVal.Field(i)
|
||||
if field.IsValid() && field.IsZero() {
|
||||
err = errors.Join(err, errors.New(structType.Field(i).Name+" is not initialized"))
|
||||
}
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
321
internal/util/struct_test.go
Normal file
321
internal/util/struct_test.go
Normal file
@@ -0,0 +1,321 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/go-openapi/swag"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type readInterface interface {
|
||||
Read(p []byte) (n int, err error)
|
||||
}
|
||||
|
||||
type writeInterface interface {
|
||||
WriteTo(w io.Writer) (n int64, err error)
|
||||
}
|
||||
|
||||
type testStruct struct {
|
||||
// satisfy only readInterface
|
||||
LimitedReader *io.LimitedReader
|
||||
Reader io.Reader
|
||||
|
||||
// satisfy both readInterface and writeInterface
|
||||
Buffer1 *bytes.Buffer
|
||||
Buffer2 *bytes.Buffer
|
||||
NetBuffer *net.Buffers
|
||||
}
|
||||
|
||||
func TestGetFieldsImplementingInvalidInput(t *testing.T) {
|
||||
// Invalid interfaceObject input param, must be a pointer to an interface
|
||||
// Pointer to a struct
|
||||
_, err := util.GetFieldsImplementing(&testStructEmpty{}, &testStructEmpty{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "interfaceObject")
|
||||
// Pointer to a pointer to an interface
|
||||
interfaceObjPtr := (*readInterface)(nil)
|
||||
_, err = util.GetFieldsImplementing(&testStructEmpty{}, &interfaceObjPtr)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "interfaceObject")
|
||||
|
||||
// Invalid structPtr input param, must be a pointer to a struct
|
||||
_, err = util.GetFieldsImplementing(testStructEmpty{}, (*readInterface)(nil))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "structPtr")
|
||||
_, err = util.GetFieldsImplementing((*readInterface)(nil), (*readInterface)(nil))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "structPtr")
|
||||
_, err = util.GetFieldsImplementing([]*testStructEmpty{}, (*readInterface)(nil))
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "structPtr")
|
||||
}
|
||||
|
||||
func TestGetFieldsImplementingNoFields(t *testing.T) {
|
||||
// No fields returned from empty structs
|
||||
structEmptyFields, err := util.GetFieldsImplementing(&testStructEmpty{}, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, structEmptyFields)
|
||||
|
||||
// No fields returned from structs with only private fields
|
||||
structPrivate := testStructPrivateFiled{privateMember: bytes.NewBufferString("my content")}
|
||||
structPrivateFields, err := util.GetFieldsImplementing(&structPrivate, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, structPrivateFields)
|
||||
|
||||
// No fields returned if struct fields are primitive
|
||||
structPrimitive := testStructPrimitives{X: 12, Y: "y", XPtr: swag.Int(15), YPtr: swag.String("YPtr")}
|
||||
structPrimitiveFields, err := util.GetFieldsImplementing(&structPrimitive, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, structPrimitiveFields)
|
||||
|
||||
// No fields returned if struct fields are structs (not pointer to a struct)
|
||||
structMemberStruct := testStructMemberStruct{Member: *bytes.NewBufferString("my content")}
|
||||
structMemberStructFields, err := util.GetFieldsImplementing(&structMemberStruct, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, structMemberStructFields)
|
||||
|
||||
// No fieds returned if an interface is not matching
|
||||
type notMatchedInterface interface {
|
||||
Read(p []byte) (n int, err error, additional []string)
|
||||
}
|
||||
testStructObj := testStruct{}
|
||||
testStructFields, err := util.GetFieldsImplementing(&testStructObj, (*notMatchedInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, testStructFields)
|
||||
}
|
||||
|
||||
func TestGetFieldsImplementingMemberStructPointer(t *testing.T) {
|
||||
content := "runs all day and never walks"
|
||||
testStructObj := testStructMemberStructPtr{
|
||||
Member: bytes.NewBufferString(content),
|
||||
}
|
||||
fields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, fields, 1)
|
||||
|
||||
output := make([]byte, len(content))
|
||||
n, err := fields[0].Read(output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(content), n)
|
||||
assert.Equal(t, content, string(output))
|
||||
}
|
||||
|
||||
func TestGetFieldsImplementingMemberInterface(t *testing.T) {
|
||||
content := "it has a bed and never sleeps"
|
||||
testStructObj := testStructMemberInterface{
|
||||
Member: bytes.NewBufferString(content),
|
||||
}
|
||||
fields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, fields, 1)
|
||||
|
||||
output := make([]byte, len(content))
|
||||
n, err := fields[0].Read(output)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(content), n)
|
||||
assert.Equal(t, content, string(output))
|
||||
}
|
||||
|
||||
func TestGetFieldsImplementingSuccess(t *testing.T) {
|
||||
// Struct not initialized
|
||||
// It's a responsibility of a user to make sure that the fields are not nil before using them.
|
||||
structNotInitialized := testStruct{}
|
||||
structNotInitializedFields, err := util.GetFieldsImplementing(&structNotInitialized, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
// There are 4 pointer members of the testStruct satisfying the interface.
|
||||
// Nil interface members are not returned.
|
||||
assert.Len(t, structNotInitializedFields, 4)
|
||||
for _, f := range structNotInitializedFields {
|
||||
assert.Nil(t, f)
|
||||
assert.Implements(t, (*readInterface)(nil), f)
|
||||
}
|
||||
|
||||
// Struct initialized
|
||||
testStructObj := testStruct{
|
||||
// satisfy only readInterface
|
||||
LimitedReader: &io.LimitedReader{N: 100},
|
||||
Reader: strings.NewReader("did you know that"),
|
||||
// satisfy both readInterface and writeInterface
|
||||
Buffer1: bytes.NewBufferString("there are rats with"),
|
||||
Buffer2: bytes.NewBufferString("human BRAIN cells transplanted"),
|
||||
NetBuffer: &net.Buffers{[]byte{0x19}},
|
||||
}
|
||||
|
||||
// Fields implementing readInterface
|
||||
readInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*readInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, readInterfaceFields, 5)
|
||||
|
||||
for _, f := range readInterfaceFields {
|
||||
assert.NotNil(t, f)
|
||||
assert.Implements(t, (*readInterface)(nil), f)
|
||||
}
|
||||
|
||||
// Fields implementing writeInterface
|
||||
writeInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*writeInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, writeInterfaceFields, 3)
|
||||
for _, f := range writeInterfaceFields {
|
||||
assert.NotNil(t, f)
|
||||
assert.Implements(t, (*writeInterface)(nil), f)
|
||||
}
|
||||
|
||||
type readWriteInterface interface {
|
||||
readInterface
|
||||
writeInterface
|
||||
}
|
||||
readWriteInterfaceFields, err := util.GetFieldsImplementing(&testStructObj, (*readWriteInterface)(nil))
|
||||
require.NoError(t, err)
|
||||
// All members implementing writeInterface implement readInterface too
|
||||
assert.Len(t, readWriteInterfaceFields, 3)
|
||||
}
|
||||
|
||||
func TestIsStructInitialized(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
testStruct interface{}
|
||||
expectError bool
|
||||
errorContains []string
|
||||
}{
|
||||
// No error cases
|
||||
{
|
||||
name: "Empty struct",
|
||||
testStruct: &testStructEmpty{},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized private field",
|
||||
testStruct: &testStructPrivateFiled{privateMember: bytes.NewBufferString("my content")},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized primitives",
|
||||
testStruct: &testStructPrimitives{
|
||||
X: 1,
|
||||
Y: "blabla",
|
||||
XPtr: new(int),
|
||||
YPtr: new(string),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized maps and slices",
|
||||
testStruct: &testStructMapsAndSlices{
|
||||
MapMember: make(map[string]int, 0),
|
||||
SliceMember: make([]int, 0),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized struct member",
|
||||
testStruct: &testStructMemberStruct{
|
||||
Member: *bytes.NewBufferString("my content"),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized struct pointer member",
|
||||
testStruct: &testStructMemberStructPtr{
|
||||
Member: bytes.NewBufferString("my content"),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "Struct with initialized interface member",
|
||||
testStruct: &testStructMemberInterface{
|
||||
Member: bytes.NewBufferString("my content"),
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
|
||||
// Error cases
|
||||
{
|
||||
name: "Struct with uninitialized private field",
|
||||
testStruct: &testStructPrivateFiled{},
|
||||
expectError: true,
|
||||
errorContains: []string{"privateMember"},
|
||||
},
|
||||
{
|
||||
name: "Struct with uninitialized primitives",
|
||||
testStruct: &testStructPrimitives{},
|
||||
expectError: true,
|
||||
errorContains: []string{"X", "Y", "XPtr", "YPtr"},
|
||||
},
|
||||
{
|
||||
name: "Struct with uninitialized maps and slices",
|
||||
testStruct: &testStructMapsAndSlices{},
|
||||
expectError: true,
|
||||
errorContains: []string{"MapMember", "SliceMember"},
|
||||
},
|
||||
{
|
||||
name: "Struct with uninitialized struct member",
|
||||
testStruct: &testStructMemberStruct{},
|
||||
expectError: true,
|
||||
errorContains: []string{"Member"},
|
||||
},
|
||||
{
|
||||
name: "Struct with uninitialized struct pointer member",
|
||||
testStruct: &testStructMemberStructPtr{},
|
||||
expectError: true,
|
||||
errorContains: []string{"Member"},
|
||||
},
|
||||
{
|
||||
name: "Struct with uninitialized interface member",
|
||||
testStruct: &testStructMemberInterface{},
|
||||
expectError: true,
|
||||
errorContains: []string{"Member"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := util.IsStructInitialized(tt.testStruct)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
for _, errText := range tt.errorContains {
|
||||
assert.Contains(t, err.Error(), errText)
|
||||
}
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type testStructEmpty struct {
|
||||
}
|
||||
|
||||
type testStructPrivateFiled struct {
|
||||
privateMember *bytes.Buffer
|
||||
}
|
||||
|
||||
type testStructPrimitives struct {
|
||||
X int
|
||||
Y string
|
||||
XPtr *int
|
||||
YPtr *string
|
||||
}
|
||||
|
||||
type testStructMapsAndSlices struct {
|
||||
MapMember map[string]int
|
||||
SliceMember []int
|
||||
}
|
||||
|
||||
type testStructMemberStruct struct {
|
||||
Member bytes.Buffer
|
||||
}
|
||||
|
||||
type testStructMemberStructPtr struct {
|
||||
Member *bytes.Buffer
|
||||
}
|
||||
|
||||
type testStructMemberInterface struct {
|
||||
Member io.Reader
|
||||
}
|
||||
113
internal/util/time.go
Normal file
113
internal/util/time.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-openapi/swag"
|
||||
)
|
||||
|
||||
const (
|
||||
DateFormat = "2006-01-02"
|
||||
monthsPerQuarter = 3
|
||||
daysPerWeek = 7
|
||||
)
|
||||
|
||||
// TimeFromString returns an instance of time.Time from a given string asuming RFC3339 format
|
||||
func TimeFromString(timeString string) (time.Time, error) {
|
||||
result, err := time.Parse(time.RFC3339, timeString)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse time string: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func DateFromString(dateString string) (time.Time, error) {
|
||||
result, err := time.Parse(DateFormat, dateString)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("failed to parse date string: %w", err)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func EndOfMonth(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month()+1, 1, 0, 0, 0, -1, d.Location())
|
||||
}
|
||||
|
||||
func EndOfPreviousMonth(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), 1, 0, 0, 0, -1, d.Location())
|
||||
}
|
||||
|
||||
func EndOfDay(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), d.Day()+1, 0, 0, 0, -1, d.Location())
|
||||
}
|
||||
|
||||
func StartOfDay(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||||
}
|
||||
|
||||
func StartOfMonth(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), 1, 0, 0, 0, 0, d.Location())
|
||||
}
|
||||
|
||||
func StartOfQuarter(d time.Time) time.Time {
|
||||
quarter := (int(d.Month()) - 1) / monthsPerQuarter
|
||||
m := quarter*monthsPerQuarter + 1
|
||||
return time.Date(d.Year(), time.Month(m), 1, 0, 0, 0, 0, d.Location())
|
||||
}
|
||||
|
||||
// StartOfWeek returns the monday (assuming week starts at monday) of the week of the date.
|
||||
func StartOfWeek(date time.Time) time.Time {
|
||||
dayOffset := int(date.Weekday()) - 1
|
||||
|
||||
// go time is starting weeks at sunday
|
||||
if dayOffset < 0 {
|
||||
dayOffset = 6
|
||||
}
|
||||
|
||||
return time.Date(date.Year(), date.Month(), date.Day()-dayOffset, 0, 0, 0, 0, date.Location())
|
||||
}
|
||||
|
||||
func Date(year int, month int, day int, loc *time.Location) time.Time {
|
||||
return time.Date(year, time.Month(month), day, 0, 0, 0, 0, loc)
|
||||
}
|
||||
|
||||
func AddWeeks(d time.Time, weeks int) time.Time {
|
||||
return d.AddDate(0, 0, daysPerWeek*weeks)
|
||||
}
|
||||
|
||||
func AddMonths(d time.Time, months int) time.Time {
|
||||
return d.AddDate(0, months, 0)
|
||||
}
|
||||
|
||||
func DayBefore(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, -1, d.Location())
|
||||
}
|
||||
|
||||
func TruncateTime(d time.Time) time.Time {
|
||||
return time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
|
||||
}
|
||||
|
||||
// MaxTime returns the latest time.Time of the given params
|
||||
func MaxTime(times ...time.Time) time.Time {
|
||||
var latestTime time.Time
|
||||
for _, t := range times {
|
||||
if t.After(latestTime) {
|
||||
latestTime = t
|
||||
}
|
||||
}
|
||||
|
||||
return latestTime
|
||||
}
|
||||
|
||||
// NonZeroTimeOrNil returns a pointer to passed time if it is not a zero time. Passing zero/uninitialised time returns nil instead.
|
||||
func NonZeroTimeOrNil(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
return swag.Time(t)
|
||||
}
|
||||
174
internal/util/time_test.go
Normal file
174
internal/util/time_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStartOfMonth(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfMonth(d))
|
||||
|
||||
d = util.Date(2020, 12, 35, time.UTC)
|
||||
expected = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfMonth(d))
|
||||
}
|
||||
|
||||
func TestTimeFromString(t *testing.T) {
|
||||
expected := time.Date(2020, 3, 29, 12, 34, 54, 0, time.UTC)
|
||||
|
||||
d, err := util.TimeFromString("2020-03-29T12:34:54Z")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, expected, d)
|
||||
}
|
||||
|
||||
func TestStartOfQuarter(t *testing.T) {
|
||||
d := util.Date(2020, 3, 31, time.UTC)
|
||||
expected := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfQuarter(d))
|
||||
|
||||
d = util.Date(2020, 1, 1, time.UTC)
|
||||
expected = time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfQuarter(d))
|
||||
|
||||
d = util.Date(2020, 12, 1, time.UTC)
|
||||
expected = time.Date(2020, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfQuarter(d))
|
||||
|
||||
d = util.Date(2020, 12, 35, time.UTC)
|
||||
expected = time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfQuarter(d))
|
||||
|
||||
d = util.Date(2020, 4, 1, time.UTC)
|
||||
expected = time.Date(2020, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfQuarter(d))
|
||||
}
|
||||
|
||||
func TestStartOfWeek(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2020, 3, 9, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfWeek(d))
|
||||
|
||||
d = util.Date(2020, 6, 15, time.UTC)
|
||||
expected = time.Date(2020, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfWeek(d))
|
||||
|
||||
d = util.Date(2020, 6, 21, time.UTC)
|
||||
expected = time.Date(2020, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.StartOfWeek(d))
|
||||
}
|
||||
|
||||
func TestDateFromString(t *testing.T) {
|
||||
res, err := util.DateFromString("2020-01-03")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.True(t, res.Equal(time.Date(2020, 1, 3, 0, 0, 0, 0, time.UTC)))
|
||||
|
||||
res, err = util.DateFromString("2020-xx-03")
|
||||
require.Error(t, err)
|
||||
assert.Empty(t, res)
|
||||
}
|
||||
|
||||
func TestEndOfMonth(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2020, 3, 31, 23, 59, 59, 999999999, time.UTC)
|
||||
assert.True(t, expected.Equal(util.EndOfMonth(d)))
|
||||
|
||||
d = util.Date(2020, 12, 35, time.UTC)
|
||||
expected = time.Date(2021, 1, 31, 23, 59, 59, 999999999, time.UTC)
|
||||
res := util.EndOfMonth(d)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
expected = time.Date(2021, 1, 31, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.TruncateTime(res))
|
||||
}
|
||||
|
||||
func TestEndOfPreviousMonth(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2020, 2, 29, 23, 59, 59, 999999999, time.UTC)
|
||||
assert.True(t, expected.Equal(util.EndOfPreviousMonth(d)))
|
||||
|
||||
d = util.Date(2020, 12, 35, time.UTC)
|
||||
expected = time.Date(2020, 12, 31, 23, 59, 59, 999999999, time.UTC)
|
||||
res := util.EndOfPreviousMonth(d)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
expected = time.Date(2020, 12, 31, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.TruncateTime(res))
|
||||
}
|
||||
|
||||
func TestStartOfDay(t *testing.T) {
|
||||
d := time.Date(2020, 3, 12, 23, 59, 59, 999999999, time.UTC)
|
||||
expected := util.Date(2020, 3, 12, time.UTC)
|
||||
assert.True(t, expected.Equal(util.StartOfDay(d)))
|
||||
|
||||
d = time.Date(2021, 1, 4, 23, 59, 59, 999999999, time.UTC)
|
||||
expected = util.Date(2020, 12, 35, time.UTC)
|
||||
res := util.StartOfDay(d)
|
||||
assert.True(t, expected.Equal(res))
|
||||
}
|
||||
|
||||
func TestEndOfDay(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2020, 3, 12, 23, 59, 59, 999999999, time.UTC)
|
||||
assert.True(t, expected.Equal(util.EndOfDay(d)))
|
||||
|
||||
d = util.Date(2020, 12, 35, time.UTC)
|
||||
expected = time.Date(2021, 1, 4, 23, 59, 59, 999999999, time.UTC)
|
||||
res := util.EndOfDay(d)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
expected = time.Date(2021, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.TruncateTime(res))
|
||||
}
|
||||
|
||||
func TestDateAdds(t *testing.T) {
|
||||
d := util.Date(2020, 3, 12, time.UTC)
|
||||
expected := time.Date(2022, 4, 12, 0, 0, 0, 0, time.UTC)
|
||||
res := util.AddMonths(d, 25)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
d = util.Date(2020, 1, 30, time.UTC)
|
||||
expected = time.Date(2020, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
res = util.AddMonths(d, 1)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
d = util.Date(2020, 1, 30, time.UTC)
|
||||
expected = time.Date(2020, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
res = util.AddWeeks(d, 5)
|
||||
assert.True(t, expected.Equal(res))
|
||||
}
|
||||
|
||||
func TestDayBefore(t *testing.T) {
|
||||
d := util.Date(2020, 3, 1, time.UTC)
|
||||
expected := time.Date(2020, 2, 29, 23, 59, 59, 999999999, time.UTC)
|
||||
res := util.DayBefore(d)
|
||||
assert.True(t, expected.Equal(res))
|
||||
|
||||
expected = time.Date(2020, 2, 29, 0, 0, 0, 0, time.UTC)
|
||||
assert.Equal(t, expected, util.TruncateTime(res))
|
||||
}
|
||||
|
||||
func TestMaxTime(t *testing.T) {
|
||||
a := time.Date(2022, 4, 12, 0, 0, 0, 1, time.UTC)
|
||||
b := time.Date(2022, 4, 12, 0, 0, 0, 2, time.UTC)
|
||||
c := time.Date(2022, 4, 12, 0, 0, 0, 0, time.UTC)
|
||||
latestTime := util.MaxTime(a, b, c)
|
||||
assert.Equal(t, b, latestTime)
|
||||
}
|
||||
|
||||
func TestNonZeroTimeOrNil(t *testing.T) {
|
||||
d := time.Time{}
|
||||
res := util.NonZeroTimeOrNil(d)
|
||||
assert.Empty(t, res)
|
||||
|
||||
d = util.Date(2021, 7, 2, time.UTC)
|
||||
res = util.NonZeroTimeOrNil(d)
|
||||
assert.Equal(t, &d, res)
|
||||
}
|
||||
55
internal/util/url/url.go
Normal file
55
internal/util/url/url.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package url
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
queryParamToken = "token"
|
||||
accountConfirmationPath = "/api/v1/auth/register"
|
||||
)
|
||||
|
||||
func PasswordResetDeeplinkURL(config config.Server, token string) (*url.URL, error) {
|
||||
u, err := url.Parse(config.Frontend.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse the base URL: %w", err)
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, config.Frontend.PasswordResetEndpoint)
|
||||
|
||||
q := u.Query()
|
||||
q.Set(queryParamToken, token)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func ConfirmationDeeplinkURL(config config.Server, token string) (*url.URL, error) {
|
||||
u, err := url.Parse(config.Echo.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse the base URL: %w", err)
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, accountConfirmationPath)
|
||||
|
||||
q := u.Query()
|
||||
q.Set(queryParamToken, token)
|
||||
u.RawQuery = q.Encode()
|
||||
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func ConfirmationRequestURL(config config.Server, token string) (*url.URL, error) {
|
||||
u, err := url.Parse(config.Echo.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse the base URL: %w", err)
|
||||
}
|
||||
|
||||
u.Path = path.Join(u.Path, accountConfirmationPath, token)
|
||||
|
||||
return u, nil
|
||||
}
|
||||
30
internal/util/wait_group.go
Normal file
30
internal/util/wait_group.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// nolint:revive
|
||||
package util
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWaitTimeout = errors.New("WaitGroup has timed out")
|
||||
)
|
||||
|
||||
// WaitTimeout waits for the waitgroup for the specified max timeout.
|
||||
// Returns nil on completion or ErrWaitTimeout if waiting timed out.
|
||||
// See https://stackoverflow.com/questions/32840687/timeout-for-waitgroup-wait
|
||||
// Note that the spawned goroutine to wg.Wait() gets leaked and will continue running detached
|
||||
func WaitTimeout(wg *sync.WaitGroup, timeout time.Duration) error {
|
||||
waitChan := make(chan struct{})
|
||||
go func() {
|
||||
defer close(waitChan)
|
||||
wg.Wait()
|
||||
}()
|
||||
select {
|
||||
case <-waitChan:
|
||||
return nil // completed normally
|
||||
case <-time.After(timeout):
|
||||
return ErrWaitTimeout // timed out
|
||||
}
|
||||
}
|
||||
48
internal/util/wait_group_test.go
Normal file
48
internal/util/wait_group_test.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package util_test
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"allaboutapps.dev/aw/go-starter/internal/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWaitTimeoutErr(t *testing.T) {
|
||||
var wg sync.WaitGroup
|
||||
var n int32
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
atomic.AddInt32(&n, 1)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
atomic.AddInt32(&n, 1)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
atomic.AddInt32(&n, 1)
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
atomic.AddInt32(&n, 1) // available after wg.Wait()
|
||||
wg.Done()
|
||||
}()
|
||||
|
||||
// timeout reached.
|
||||
err := util.WaitTimeout(&wg, 200*time.Millisecond)
|
||||
assert.Equal(t, util.ErrWaitTimeout, err)
|
||||
assert.Equal(t, int32(3), atomic.LoadInt32(&n))
|
||||
|
||||
// ok (after timeout).
|
||||
err = util.WaitTimeout(&wg, 800*time.Second)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int32(4), atomic.LoadInt32(&n))
|
||||
}
|
||||
Reference in New Issue
Block a user