Compare commits
4 commits
Author | SHA1 | Date | |
---|---|---|---|
c8b78dbb86 | |||
1823a432f8 | |||
7e32ff6f91 | |||
98eb17d9f3 |
11 changed files with 987 additions and 3 deletions
26
README.md
26
README.md
|
@ -1,7 +1,25 @@
|
|||
# gotoolkit
|
||||
|
||||
[](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
131
cache/file.go
vendored
Normal 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
412
cache/file_test.go
vendored
Normal 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
68
cache/memory.go
vendored
Normal 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
285
cache/memory_test.go
vendored
Normal 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
14
cache/options.go
vendored
Normal 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
4
go.mod
|
@ -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
2
go.sum
|
@ -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
22
model/cache.go
Normal 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
20
paths/user.go
Normal 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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue