Compare commits

...

4 commits
v0.1.0 ... main

Author SHA1 Message Date
c8b78dbb86
fix(templates): match *.html only 2025-04-04 19:17:28 +02:00
1823a432f8
fix(templates): match html files in root folder too 2025-04-04 19:03:58 +02:00
7e32ff6f91
fix: use fs.FS instead of embed.FS as argument 2025-04-04 18:45:21 +02:00
98eb17d9f3 feat: cache + paths (#1)
- Added helper function `ExpandUser`
- Added basic cache impmentations for in-memory and file based caches.

Reviewed-on: #1
Co-authored-by: Felipe M. <me@fmartingr.com>
Co-committed-by: Felipe M. <me@fmartingr.com>
2025-03-23 23:46:28 +01:00
11 changed files with 987 additions and 3 deletions

View file

@ -1,7 +1,25 @@
# gotoolkit
[![Go Reference](https://pkg.go.dev/badge/git.nakama.town/fmartingr/gotoolkit.svg)](https://pkg.go.dev/git.nakama.town/fmartingr/gotoolkit)
A set of basic tools to develop Go applications.
# Index
- [Cache](#cache)
- [Database](#database)
- [Paths](#paths)
- [Service & Servers](#service--servers)
- [Template](#template)
## Cache
A basic cache engine to manage the cache of the application.
```go
cache := cache.NewMemoryCache()
```
## Database
A basic database engine to manage the connection to a database.
@ -13,6 +31,14 @@ if err != nil {
}
```
## Paths
A basic utility to manage the paths of the application.
```go
path := paths.ExpandUser("~/myapp")
```
## Service & Servers
A basic way to expose servers within one service.

131
cache/file.go vendored Normal file
View file

@ -0,0 +1,131 @@
package cache
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
)
var _ model.Cache = (*FileCache)(nil)
type FileCache struct {
path string
}
func (c *FileCache) Get(key string) (result any, err error) {
path := c.getPathForItem(key)
if _, err := os.Stat(path + ".metadata"); os.IsNotExist(err) {
return result, model.ErrCacheKeyDontExist
}
metadata, err := c.getMetadata(key)
if err != nil {
return result, fmt.Errorf("error getting metadata: %s", err)
}
if metadata.TTL != nil && metadata.TTL.Before(time.Now()) {
if err := c.Delete(key); err != nil {
return result, fmt.Errorf("error deleting cache key: %s", err)
}
return result, model.ErrCacheKeyDontExist
}
contents, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) || errors.Is(err, os.ErrPermission) {
return result, model.ErrCacheKeyDontExist
}
return
}
return contents, nil
}
func (c *FileCache) Set(key string, value any, opts ...model.CacheOption) error {
path := c.getPathForItem(key)
metadata := model.CacheItem{
Key: key,
}
for _, opt := range opts {
opt(&metadata)
}
if err := c.setMetadata(key, metadata); err != nil {
return fmt.Errorf("error setting metadata: %s", err)
}
if err := os.WriteFile(path, value.([]byte), 0766); err != nil {
return fmt.Errorf("error writting cache file: %s", err)
}
return nil
}
func (c *FileCache) Delete(key string) error {
path := c.getPathForItem(key)
if err := os.Remove(path); err != nil {
return fmt.Errorf("error deleting cache file: %s", err)
}
if err := os.Remove(path + ".metadata"); err != nil {
return fmt.Errorf("error deleting cache metadata: %s", err)
}
return nil
}
func (c *FileCache) getPathForItem(key string) string {
return filepath.Join(c.path, key)
}
func (c *FileCache) getMetadata(key string) (model.CacheItem, error) {
path := c.getPathForItem(key) + ".metadata"
contents, err := os.ReadFile(path)
if err != nil {
return model.CacheItem{}, fmt.Errorf("error reading cache file: %s", err)
}
var metadata model.CacheItem
if err := json.Unmarshal(contents, &metadata); err != nil {
return model.CacheItem{}, fmt.Errorf("error unmarshalling cache file: %s", err)
}
return metadata, nil
}
func (c *FileCache) setMetadata(key string, metadata model.CacheItem) error {
path := c.getPathForItem(key) + ".metadata"
contents, err := json.Marshal(metadata)
if err != nil {
return fmt.Errorf("error marshalling cache file: %s", err)
}
return os.WriteFile(path, contents, 0644)
}
func NewFileCache(folderName string) (*FileCache, error) {
userCacheDir, err := os.UserCacheDir()
if err != nil {
return nil, fmt.Errorf("Could not retrieve user cache directory: %w", err)
}
path := filepath.Join(userCacheDir, folderName)
if err := os.MkdirAll(path, 0755); err != nil {
return nil, fmt.Errorf("couldn't create cache directory: %w", err)
}
return &FileCache{
path: path,
}, nil
}

412
cache/file_test.go vendored Normal file
View file

@ -0,0 +1,412 @@
package cache
import (
"os"
"path/filepath"
"testing"
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewFileCache(t *testing.T) {
// Create a test folder name
testFolderName := "test-cache"
// Test initialization with the temp directory
cache, err := NewFileCache(testFolderName)
require.NoError(t, err, "NewFileCache should not return an error")
require.NotNil(t, cache, "NewFileCache should return a non-nil cache")
// Verify the directory was created (not checking the exact path as it depends on the os.UserCacheDir output)
dirInfo, err := os.Stat(cache.path)
require.NoError(t, err, "Cache directory should exist")
assert.True(t, dirInfo.IsDir(), "Cache path should be a directory")
}
func TestFileCache_Set(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
cache := setupTestFileCache(t, tempDir)
// Test simple set operation
testKey := "test-key"
testValue := []byte("test-value")
err := cache.Set(testKey, testValue)
require.NoError(t, err, "Set should not return an error")
// Verify the file exists
filePath := filepath.Join(cache.path, testKey)
assert.FileExists(t, filePath, "Cache file should exist")
assert.FileExists(t, filePath+".metadata", "Cache metadata file should exist")
// Read the file contents
content, err := os.ReadFile(filePath)
require.NoError(t, err, "Should be able to read cache file")
assert.Equal(t, testValue, content, "File content should match set value")
// Test set with TTL
ttlKey := "ttl-key"
ttlValue := []byte("ttl-value")
ttlDuration := 1 * time.Hour
withTTL := func(item *model.CacheItem) {
expiry := time.Now().Add(ttlDuration)
item.TTL = &expiry
}
err = cache.Set(ttlKey, ttlValue, withTTL)
require.NoError(t, err, "Set with TTL should not return an error")
// Verify file exists
ttlFilePath := filepath.Join(cache.path, ttlKey)
assert.FileExists(t, ttlFilePath, "Cache file with TTL should exist")
assert.FileExists(t, ttlFilePath+".metadata", "Cache metadata file with TTL should exist")
// Verify the metadata has TTL set
metadata, err := cache.getMetadata(ttlKey)
require.NoError(t, err, "Should be able to get metadata")
assert.NotNil(t, metadata.TTL, "TTL should be set in metadata")
}
func TestFileCache_SetTableDriven(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
key string
value []byte
withTTL bool
ttlDelta time.Duration
}{
{
name: "basic set without TTL",
key: "key1",
value: []byte("value1"),
},
{
name: "set with future TTL",
key: "key2",
value: []byte("value2"),
withTTL: true,
ttlDelta: 1 * time.Hour, // Future time
},
{
name: "set with past TTL",
key: "key3",
value: []byte("value3"),
withTTL: true,
ttlDelta: -1 * time.Hour, // Past time
},
{
name: "set with empty value",
key: "key4",
value: []byte{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := setupTestFileCache(t, tempDir)
var opts []model.CacheOption
if tt.withTTL {
opts = append(opts, func(item *model.CacheItem) {
expiry := time.Now().Add(tt.ttlDelta)
item.TTL = &expiry
})
}
err := cache.Set(tt.key, tt.value, opts...)
require.NoError(t, err, "Set should not return an error")
// Verify file and metadata exist
filePath := filepath.Join(cache.path, tt.key)
assert.FileExists(t, filePath, "Cache file should exist")
assert.FileExists(t, filePath+".metadata", "Cache metadata file should exist")
// Verify file content
content, err := os.ReadFile(filePath)
require.NoError(t, err, "Should be able to read cache file")
assert.Equal(t, tt.value, content, "File content should match set value")
// Verify metadata
metadata, err := cache.getMetadata(tt.key)
require.NoError(t, err, "Should be able to get metadata")
assert.Equal(t, tt.key, metadata.Key, "Metadata key should match")
if tt.withTTL {
assert.NotNil(t, metadata.TTL, "TTL should be set in metadata")
expectedTime := time.Now().Add(tt.ttlDelta)
assert.WithinDuration(t, expectedTime, *metadata.TTL, 2*time.Second, "TTL should be close to expected value")
} else {
assert.Nil(t, metadata.TTL, "TTL should not be set for items without TTL")
}
})
}
}
func TestFileCache_Get(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
cache := setupTestFileCache(t, tempDir)
// Set up test data
validKey := "valid-key"
validValue := []byte("valid-value")
err := cache.Set(validKey, validValue)
require.NoError(t, err, "Set should not return an error for setup")
// Test getting valid key
retrievedValue, err := cache.Get(validKey)
require.NoError(t, err, "Get should not return an error for valid key")
assert.Equal(t, validValue, retrievedValue, "Retrieved value should match original")
// Test getting non-existent key
_, err = cache.Get("nonexistent-key")
assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Get should return ErrCacheKeyDontExist for non-existent key")
// Test getting expired key
expiredKey := "expired-key"
expiredValue := []byte("expired-value")
expiredTTL := func(item *model.CacheItem) {
expiry := time.Now().Add(-1 * time.Minute) // Past time
item.TTL = &expiry
}
err = cache.Set(expiredKey, expiredValue, expiredTTL)
require.NoError(t, err, "Set should not return an error for expired key setup")
_, err = cache.Get(expiredKey)
assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Get should return ErrCacheKeyDontExist for expired key")
// Verify the expired files are deleted
expiredFilePath := filepath.Join(cache.path, expiredKey)
expiredMetadataPath := expiredFilePath + ".metadata"
assert.NoFileExists(t, expiredFilePath, "Expired cache file should be deleted")
assert.NoFileExists(t, expiredMetadataPath, "Expired cache metadata file should be deleted")
}
func TestFileCache_GetTableDriven(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
setupKeys map[string][]byte
setupTTLs map[string]time.Duration
getKey string
expectValue []byte
expectError error
checkFileDeleted bool
}{
{
name: "get existing key",
setupKeys: map[string][]byte{
"test-key": []byte("test-value"),
},
getKey: "test-key",
expectValue: []byte("test-value"),
expectError: nil,
},
{
name: "get non-existent key",
setupKeys: map[string][]byte{
"test-key": []byte("test-value"),
},
getKey: "nonexistent-key",
expectValue: nil,
expectError: model.ErrCacheKeyDontExist,
},
{
name: "get expired key",
setupKeys: map[string][]byte{
"expired-key": []byte("expired-value"),
},
setupTTLs: map[string]time.Duration{
"expired-key": -1 * time.Minute, // Past time
},
getKey: "expired-key",
expectValue: nil,
expectError: model.ErrCacheKeyDontExist,
checkFileDeleted: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := setupTestFileCache(t, tempDir)
// Setup test data
for key, value := range tt.setupKeys {
var opts []model.CacheOption
if ttl, ok := tt.setupTTLs[key]; ok {
opts = append(opts, func(item *model.CacheItem) {
expiry := time.Now().Add(ttl)
item.TTL = &expiry
})
}
err := cache.Set(key, value, opts...)
require.NoError(t, err, "Setup: Set should not return an error")
}
// Test get
value, err := cache.Get(tt.getKey)
if tt.expectError != nil {
assert.ErrorIs(t, err, tt.expectError)
assert.Nil(t, value)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.expectValue, value)
}
// Check if expired files are deleted
if tt.checkFileDeleted {
filePath := filepath.Join(cache.path, tt.getKey)
metadataPath := filePath + ".metadata"
assert.NoFileExists(t, filePath, "Expired cache file should be deleted")
assert.NoFileExists(t, metadataPath, "Expired cache metadata file should be deleted")
}
})
}
}
func TestFileCache_Delete(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
cache := setupTestFileCache(t, tempDir)
// Set up test data
testKey := "delete-key"
testValue := []byte("delete-value")
err := cache.Set(testKey, testValue)
require.NoError(t, err, "Set should not return an error for setup")
// Verify files exist before delete
filePath := filepath.Join(cache.path, testKey)
metadataPath := filePath + ".metadata"
assert.FileExists(t, filePath, "Cache file should exist before delete")
assert.FileExists(t, metadataPath, "Cache metadata file should exist before delete")
// Test delete
err = cache.Delete(testKey)
require.NoError(t, err, "Delete should not return an error")
// Verify files are deleted
assert.NoFileExists(t, filePath, "Cache file should be deleted")
assert.NoFileExists(t, metadataPath, "Cache metadata file should be deleted")
// Test deleting non-existent key
err = cache.Delete("nonexistent-key")
assert.Error(t, err, "Delete should return an error for non-existent key")
}
func TestFileCache_DeleteTableDriven(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
setupKey string
setupValue []byte
deleteKey string
expectError bool
checkNotExist bool
}{
{
name: "delete existing key",
setupKey: "existing-key",
setupValue: []byte("existing-value"),
deleteKey: "existing-key",
expectError: false,
checkNotExist: true,
},
{
name: "delete non-existent key",
deleteKey: "nonexistent-key",
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := setupTestFileCache(t, tempDir)
// Setup test data if needed
if tt.setupKey != "" {
err := cache.Set(tt.setupKey, tt.setupValue)
require.NoError(t, err, "Setup: Set should not return an error")
// Verify files exist before delete
filePath := filepath.Join(cache.path, tt.setupKey)
metadataPath := filePath + ".metadata"
assert.FileExists(t, filePath, "Cache file should exist before delete")
assert.FileExists(t, metadataPath, "Cache metadata file should exist before delete")
}
// Test delete
err := cache.Delete(tt.deleteKey)
if tt.expectError {
assert.Error(t, err, "Delete should return an error")
} else {
assert.NoError(t, err, "Delete should not return an error")
}
// Check files are deleted if needed
if tt.checkNotExist {
filePath := filepath.Join(cache.path, tt.deleteKey)
metadataPath := filePath + ".metadata"
assert.NoFileExists(t, filePath, "Cache file should be deleted")
assert.NoFileExists(t, metadataPath, "Cache metadata file should be deleted")
}
})
}
}
func TestFileCache_GetPathForItem(t *testing.T) {
tempDir := t.TempDir()
cache := setupTestFileCache(t, tempDir)
key := "test-key"
expectedPath := filepath.Join(cache.path, key)
actualPath := cache.getPathForItem(key)
assert.Equal(t, expectedPath, actualPath, "Path for item should match expected format")
}
func TestFileCache_GetSetMetadata(t *testing.T) {
tempDir := t.TempDir()
cache := setupTestFileCache(t, tempDir)
// Create test metadata
key := "metadata-key"
now := time.Now()
metadata := model.CacheItem{
Key: key,
Value: nil,
TTL: &now,
}
// Test setMetadata
err := cache.setMetadata(key, metadata)
require.NoError(t, err, "setMetadata should not return an error")
// Verify metadata file exists
metadataPath := filepath.Join(cache.path, key) + ".metadata"
assert.FileExists(t, metadataPath, "Metadata file should exist")
// Test getMetadata
retrievedMetadata, err := cache.getMetadata(key)
require.NoError(t, err, "getMetadata should not return an error")
assert.Equal(t, key, retrievedMetadata.Key, "Retrieved metadata key should match original")
assert.WithinDuration(t, now, *retrievedMetadata.TTL, time.Second, "Retrieved metadata TTL should be close to original")
}
// Helper function to create a FileCache for testing
func setupTestFileCache(t *testing.T, tempDir string) *FileCache {
return &FileCache{
path: tempDir,
}
}

68
cache/memory.go vendored Normal file
View file

@ -0,0 +1,68 @@
package cache
import (
"sync"
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
)
var _ model.Cache = (*MemoryCache)(nil)
type MemoryCache struct {
data map[string]model.CacheItem
dataMu sync.RWMutex
}
func (c *MemoryCache) Get(key string) (result any, err error) {
c.dataMu.RLock()
item, exists := c.data[key]
if !exists {
c.dataMu.RUnlock()
return result, model.ErrCacheKeyDontExist
}
// Check expiration while still holding the lock
if item.TTL != nil && item.TTL.Before(time.Now()) {
c.dataMu.RUnlock()
_ = c.Delete(key)
return result, model.ErrCacheKeyDontExist
}
value := item.Value
c.dataMu.RUnlock()
return value, nil
}
func (c *MemoryCache) Set(key string, value any, opts ...model.CacheOption) error {
item := model.CacheItem{
Key: key,
Value: value,
}
for _, opt := range opts {
opt(&item)
}
c.dataMu.Lock()
c.data[key] = item
c.dataMu.Unlock()
return nil
}
func (c *MemoryCache) Delete(key string) error {
c.dataMu.Lock()
delete(c.data, key)
c.dataMu.Unlock()
return nil
}
func NewMemoryCache() *MemoryCache {
return &MemoryCache{
data: make(map[string]model.CacheItem),
dataMu: sync.RWMutex{},
}
}

285
cache/memory_test.go vendored Normal file
View file

@ -0,0 +1,285 @@
package cache
import (
"sync"
"testing"
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewMemoryCache(t *testing.T) {
cache := NewMemoryCache()
require.NotNil(t, cache, "NewMemoryCache returned nil")
require.NotNil(t, cache.data, "Cache data map was not initialized")
}
func TestMemoryCache_Set(t *testing.T) {
cache := NewMemoryCache()
// Test basic set
err := cache.Set("key1", "value1")
require.NoError(t, err, "Set returned unexpected error")
// Verify the item was stored correctly
cache.dataMu.RLock()
item, exists := cache.data["key1"]
cache.dataMu.RUnlock()
require.True(t, exists, "Set did not store the item in the cache")
assert.Equal(t, "key1", item.Key, "Wrong key stored")
assert.Equal(t, "value1", item.Value, "Wrong value stored")
assert.Nil(t, item.TTL, "TTL should be nil")
// Test set with TTL
withTTL := func(item *model.CacheItem) {
expiry := time.Now().Add(10 * time.Minute)
item.TTL = &expiry
}
err = cache.Set("key2", "value2", withTTL)
require.NoError(t, err, "Set with TTL returned unexpected error")
// Verify the item with TTL was stored correctly
cache.dataMu.RLock()
itemWithTTL, exists := cache.data["key2"]
cache.dataMu.RUnlock()
require.True(t, exists, "Set did not store the item with TTL in the cache")
assert.NotNil(t, itemWithTTL.TTL, "TTL option was not applied")
}
func TestMemoryCache_SetTableDriven(t *testing.T) {
tests := []struct {
name string
key string
value interface{}
ttl *time.Duration
}{
{
name: "string value without TTL",
key: "string-key",
value: "string-value",
ttl: nil,
},
{
name: "int value without TTL",
key: "int-key",
value: 42,
ttl: nil,
},
{
name: "struct value without TTL",
key: "struct-key",
value: struct{ Name string }{"test"},
ttl: nil,
},
{
name: "string value with TTL",
key: "string-key-ttl",
value: "string-value-ttl",
ttl: ptrDuration(5 * time.Minute),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewMemoryCache()
var opts []model.CacheOption
if tt.ttl != nil {
opts = append(opts, func(item *model.CacheItem) {
expiry := time.Now().Add(*tt.ttl)
item.TTL = &expiry
})
}
err := cache.Set(tt.key, tt.value, opts...)
assert.NoError(t, err)
cache.dataMu.RLock()
item, exists := cache.data[tt.key]
cache.dataMu.RUnlock()
assert.True(t, exists)
assert.Equal(t, tt.key, item.Key)
assert.Equal(t, tt.value, item.Value)
if tt.ttl != nil {
assert.NotNil(t, item.TTL)
} else {
assert.Nil(t, item.TTL)
}
})
}
}
func TestMemoryCache_Get(t *testing.T) {
cache := NewMemoryCache()
// Test getting non-existent key
_, err := cache.Get("nonexistent")
assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Wrong error returned for non-existent key")
// Test getting existing key
require.NoError(t, cache.Set("key1", "value1"))
value, err := cache.Get("key1")
require.NoError(t, err, "Get returned unexpected error")
assert.Equal(t, "value1", value, "Wrong value returned")
// Test getting expired key
expiredTTL := func(item *model.CacheItem) {
expiry := time.Now().Add(-1 * time.Minute) // Past time
item.TTL = &expiry
}
require.NoError(t, cache.Set("expired", "value", expiredTTL))
_, err = cache.Get("expired")
assert.ErrorIs(t, err, model.ErrCacheKeyDontExist, "Wrong error returned for expired item")
// Verify the expired key was deleted
cache.dataMu.RLock()
_, exists := cache.data["expired"]
cache.dataMu.RUnlock()
assert.False(t, exists, "Expired item was not deleted during Get")
}
func TestMemoryCache_GetTableDriven(t *testing.T) {
tests := []struct {
name string
setupKey string
setupValue any
setupTTL *time.Duration
getKey string
expectValue any
expectError error
}{
{
name: "get existing key",
setupKey: "test-key",
setupValue: "test-value",
getKey: "test-key",
expectValue: "test-value",
expectError: nil,
},
{
name: "get non-existent key",
setupKey: "test-key",
setupValue: "test-value",
getKey: "different-key",
expectValue: nil,
expectError: model.ErrCacheKeyDontExist,
},
{
name: "get expired key",
setupKey: "expired-key",
setupValue: "expired-value",
setupTTL: ptrDuration(-1 * time.Minute), // Negative to create an expired key
getKey: "expired-key",
expectValue: nil,
expectError: model.ErrCacheKeyDontExist,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cache := NewMemoryCache()
// Setup
var opts []model.CacheOption
if tt.setupTTL != nil {
opts = append(opts, func(item *model.CacheItem) {
expiry := time.Now().Add(*tt.setupTTL)
item.TTL = &expiry
})
}
if tt.setupKey != "" {
err := cache.Set(tt.setupKey, tt.setupValue, opts...)
require.NoError(t, err)
}
// Test
value, err := cache.Get(tt.getKey)
if tt.expectError != nil {
assert.ErrorIs(t, err, tt.expectError)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expectValue, value)
// If it was an expired key, ensure it's deleted
if tt.setupTTL != nil && *tt.setupTTL < 0 {
cache.dataMu.RLock()
_, exists := cache.data[tt.setupKey]
cache.dataMu.RUnlock()
assert.False(t, exists, "Expired item was not deleted during Get")
}
})
}
}
func TestMemoryCache_Delete(t *testing.T) {
cache := NewMemoryCache()
// Setup test data
require.NoError(t, cache.Set("key1", "value1"))
// Test deleting existing key
err := cache.Delete("key1")
require.NoError(t, err, "Delete returned unexpected error")
// Verify the key was deleted
cache.dataMu.RLock()
_, exists := cache.data["key1"]
cache.dataMu.RUnlock()
assert.False(t, exists, "Item was not deleted from cache")
// Test deleting non-existent key (should not error)
err = cache.Delete("nonexistent")
assert.NoError(t, err, "Delete on non-existent key returned error")
}
func TestMemoryCache_Concurrency(t *testing.T) {
cache := NewMemoryCache()
const goroutines = 10
const operations = 100
wg := sync.WaitGroup{}
wg.Add(goroutines * 2)
// Writers
for i := range goroutines {
go func(id int) {
for j := range operations {
key := "key" + string(rune('A'+id)) + string(rune('0'+j%10))
err := cache.Set(key, j)
assert.NoError(t, err)
}
wg.Done()
}(i)
}
// Readers
for i := range goroutines {
go func(id int) {
for j := range operations {
key := "key" + string(rune('A'+id)) + string(rune('0'+j%10))
_, _ = cache.Get(key) // We ignore errors here as keys might not exist yet
}
wg.Done()
}(i)
}
// Wait for all goroutines to complete
wg.Wait()
// If we reached here without deadlock or panic, the test passes
}
// Helper function to create duration pointers
func ptrDuration(d time.Duration) *time.Duration {
return &d
}

14
cache/options.go vendored Normal file
View file

@ -0,0 +1,14 @@
package cache
import (
"time"
"git.nakama.town/fmartingr/gotoolkit/model"
)
func WithTTL(ttl time.Duration) model.CacheOption {
return func(item *model.CacheItem) {
exp := time.Now().Add(ttl)
item.TTL = &exp
}
}

4
go.mod
View file

@ -4,17 +4,21 @@ go 1.23.3
require (
github.com/pelletier/go-toml/v2 v2.2.3
github.com/stretchr/testify v1.9.0
modernc.org/sqlite v1.34.1
)
require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect

2
go.sum
View file

@ -27,6 +27,8 @@ golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=

22
model/cache.go Normal file
View file

@ -0,0 +1,22 @@
package model
import (
"errors"
"time"
)
var ErrCacheKeyDontExist = errors.New("cache key don't exist")
type Cache interface {
Get(key string) (any, error)
Set(key string, value any, opts ...CacheOption) error
Delete(key string) error
}
type CacheItem struct {
Key string
Value any
TTL *time.Time
}
type CacheOption func(item *CacheItem)

20
paths/user.go Normal file
View file

@ -0,0 +1,20 @@
package paths
import (
"os/user"
"path/filepath"
"strings"
)
func ExpandUser(providedPath string) string {
var path string = providedPath
usr, _ := user.Current()
dir := usr.HomeDir
if providedPath == "~" {
path = dir
} else if strings.HasPrefix(providedPath, "~/") {
path = filepath.Join(dir, providedPath[2:])
}
return path
}

View file

@ -2,9 +2,9 @@ package template
import (
"bytes"
"embed"
"fmt"
"html/template"
"io/fs"
)
// Engine is a template engine
@ -24,8 +24,8 @@ func (e *Engine) Render(name string, data any) ([]byte, error) {
}
// NewTemplateEngine creates a new template engine from the given templates
func NewEngine(templates embed.FS) (*Engine, error) {
tmpls, err := template.ParseFS(templates, "**/*.html")
func NewEngine(templates fs.FS) (*Engine, error) {
tmpls, err := template.ParseFS(templates, "*.html")
if err != nil {
return nil, fmt.Errorf("failed to parse templates: %w", err)
}