go-cache/cache.go
2026-03-29 15:16:49 +00:00

258 lines
6.7 KiB
Go

// Package cache provides a storage-agnostic, JSON-based cache backed by any io.Medium.
package cache
import (
"io/fs"
"time"
"dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// DefaultTTL is the default cache expiry time.
const DefaultTTL = 1 * time.Hour
// Cache stores JSON-encoded entries in a Medium-backed cache rooted at baseDir.
type Cache struct {
medium coreio.Medium
baseDir string
timeToLive time.Duration
}
// Entry is the serialized cache record written to the backing Medium.
type Entry struct {
Data any `json:"data"`
CachedAt time.Time `json:"cached_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// New creates a cache and applies default Medium, base directory, and TTL values
// when callers pass zero values.
//
// c, err := cache.New(coreio.Local, "/tmp/cache", time.Hour)
func New(medium coreio.Medium, baseDir string, timeToLive time.Duration) (*Cache, error) {
if medium == nil {
medium = coreio.Local
}
if baseDir == "" {
currentWorkingDirectory := currentWorkingDirectory()
if currentWorkingDirectory == "" || currentWorkingDirectory == "." {
return nil, core.E("cache.New", "failed to resolve current working directory", nil)
}
baseDir = normalizePath(core.JoinPath(currentWorkingDirectory, ".core", "cache"))
} else {
baseDir = absolutePath(baseDir)
}
if timeToLive == 0 {
timeToLive = DefaultTTL
}
if err := medium.EnsureDir(baseDir); err != nil {
return nil, core.E("cache.New", "failed to create cache directory", err)
}
return &Cache{
medium: medium,
baseDir: baseDir,
timeToLive: timeToLive,
}, nil
}
// Path returns the storage path used for key and rejects path traversal
// attempts.
//
// path, err := c.Path("github/acme/repos")
func (cacheInstance *Cache) Path(key string) (string, error) {
cacheBaseDir := absolutePath(cacheInstance.baseDir)
cachePath := absolutePath(core.JoinPath(cacheBaseDir, key+".json"))
cachePathPrefix := normalizePath(core.Concat(cacheBaseDir, pathSeparator()))
if cachePath != cacheBaseDir && !core.HasPrefix(cachePath, cachePathPrefix) {
return "", core.E("cache.Path", "invalid cache key: path traversal attempt", nil)
}
return cachePath, nil
}
// Get unmarshals the cached item into dest if it exists and has not expired.
//
// found, err := c.Get("github/acme/repos", &repos)
func (cacheInstance *Cache) Get(key string, dest any) (bool, error) {
cachePath, err := cacheInstance.Path(key)
if err != nil {
return false, err
}
rawData, err := cacheInstance.medium.Read(cachePath)
if err != nil {
if core.Is(err, fs.ErrNotExist) {
return false, nil
}
return false, core.E("cache.Get", "failed to read cache file", err)
}
var entry Entry
entryUnmarshalResult := core.JSONUnmarshalString(rawData, &entry)
if !entryUnmarshalResult.OK {
return false, nil
}
if time.Now().After(entry.ExpiresAt) {
return false, nil
}
dataMarshalResult := core.JSONMarshal(entry.Data)
if !dataMarshalResult.OK {
return false, core.E("cache.Get", "failed to marshal cached data", dataMarshalResult.Value.(error))
}
if err := core.JSONUnmarshal(dataMarshalResult.Value.([]byte), dest); !err.OK {
return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error))
}
return true, nil
}
// Set marshals data and stores it in the cache.
//
// err := c.Set("github/acme/repos", repos)
func (cacheInstance *Cache) Set(key string, data any) error {
cachePath, err := cacheInstance.Path(key)
if err != nil {
return err
}
if err := cacheInstance.medium.EnsureDir(core.PathDir(cachePath)); err != nil {
return core.E("cache.Set", "failed to create directory", err)
}
now := time.Now()
entry := Entry{
Data: data,
CachedAt: now,
ExpiresAt: now.Add(cacheInstance.timeToLive),
}
entryMarshalResult := core.JSONMarshal(entry)
if !entryMarshalResult.OK {
return core.E("cache.Set", "failed to marshal cache entry", entryMarshalResult.Value.(error))
}
if err := cacheInstance.medium.Write(cachePath, string(entryMarshalResult.Value.([]byte))); err != nil {
return core.E("cache.Set", "failed to write cache file", err)
}
return nil
}
// Delete removes the cached item for key.
//
// err := c.Delete("github/acme/repos")
func (cacheInstance *Cache) Delete(key string) error {
cachePath, err := cacheInstance.Path(key)
if err != nil {
return err
}
err = cacheInstance.medium.Delete(cachePath)
if core.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return core.E("cache.Delete", "failed to delete cache file", err)
}
return nil
}
// Clear removes all cached items under the cache base directory.
//
// err := c.Clear()
func (cacheInstance *Cache) Clear() error {
if err := cacheInstance.medium.DeleteAll(cacheInstance.baseDir); err != nil {
return core.E("cache.Clear", "failed to clear cache", err)
}
return nil
}
// Age reports how long ago key was cached, or -1 if it is missing or unreadable.
//
// age := c.Age("github/acme/repos")
func (cacheInstance *Cache) Age(key string) time.Duration {
cachePath, err := cacheInstance.Path(key)
if err != nil {
return -1
}
rawData, err := cacheInstance.medium.Read(cachePath)
if err != nil {
return -1
}
var entry Entry
entryUnmarshalResult := core.JSONUnmarshalString(rawData, &entry)
if !entryUnmarshalResult.OK {
return -1
}
return time.Since(entry.CachedAt)
}
// GitHub-specific cache keys
// GitHubReposKey returns the cache key used for an organisation's repo list.
//
// key := cache.GitHubReposKey("acme")
func GitHubReposKey(org string) string {
return core.JoinPath("github", org, "repos")
}
// GitHubRepoKey returns the cache key used for a repository metadata entry.
//
// key := cache.GitHubRepoKey("acme", "widgets")
func GitHubRepoKey(org, repo string) string {
return core.JoinPath("github", org, repo, "meta")
}
func pathSeparator() string {
if directorySeparator := core.Env("DS"); directorySeparator != "" {
return directorySeparator
}
return "/"
}
func normalizePath(path string) string {
directorySeparator := pathSeparator()
normalized := core.Replace(path, "\\", directorySeparator)
if directorySeparator != "/" {
normalized = core.Replace(normalized, "/", directorySeparator)
}
return core.CleanPath(normalized, directorySeparator)
}
func absolutePath(path string) string {
normalizedPath := normalizePath(path)
if core.PathIsAbs(normalizedPath) {
return normalizedPath
}
currentWorkingDirectory := currentWorkingDirectory()
if currentWorkingDirectory == "" || currentWorkingDirectory == "." {
return normalizedPath
}
return normalizePath(core.JoinPath(currentWorkingDirectory, normalizedPath))
}
func currentWorkingDirectory() string {
workingDirectory := normalizePath(core.Env("PWD"))
if workingDirectory != "" && workingDirectory != "." {
return workingDirectory
}
return normalizePath(core.Env("DIR_CWD"))
}