(Feat): Initial Commit
Some checks failed
Build & Test / build-test (push) Has been cancelled
Build & Test / swagger-codegen-cli (push) Has been cancelled
CodeQL / Analyze (go) (push) Has been cancelled

This commit is contained in:
2026-07-03 19:41:31 +05:30
commit 7e940c83a7
461 changed files with 45002 additions and 0 deletions

11
internal/util/bool.go Normal file
View 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
}

View 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))
}

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

View 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)
})
}

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

View 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
View 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)
}

View 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
View 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)
}

View 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
View 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
View 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)
}

View 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
View 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")
}

View 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
View 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)
}

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

View 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
View 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...)}
}

View 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)
}

View 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))
}

View 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)
})
}

View 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...)
}

View 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, "':* & '") + "':*"
}

View 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)
}

View 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))
}

View 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
View 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
View 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
View File

@@ -0,0 +1,39 @@
// nolint:revive
package util
import (
"fmt"
"os"
"time"
)
// TouchFile creates an empty file if the file doesnt 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
View 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")
}

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

View 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"
}

View 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", &params.Memory, &params.Time, &params.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
}

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

View 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)
}

View 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
View 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
View 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
View 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
View 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
View 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
})
}

View 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
View 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
View 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
View 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
View 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)
}

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

View 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()))
}

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

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

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

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

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

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

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

View 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))
}