diff --git a/plugins/README.md b/plugins/README.md index 18d6384f7..27eec9a6a 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -300,6 +300,96 @@ func main() {} To schedule a task from your plugin, use the generated SDK functions (see `plugins/host/go/nd_host_scheduler.go`). +### Cache + +Allows plugins to store and retrieve data in an in-memory TTL-based cache. This is useful for caching API responses, storing session tokens, or persisting state across plugin invocations. + +**Important:** The cache is in-memory only and will be lost on server restart. Plugins should handle cache misses gracefully. + +#### Using the Cache Host Service + +To use the cache, plugins call these host functions (provided by Navidrome): + +| Host Function | Parameters | Description | +|----------------------|--------------------------------|------------------------------------------------| +| `cache_setstring` | `key, value, ttl_seconds` | Store a string value | +| `cache_getstring` | `key` | Retrieve a string value | +| `cache_setint` | `key, value, ttl_seconds` | Store an integer value | +| `cache_getint` | `key` | Retrieve an integer value | +| `cache_setfloat` | `key, value, ttl_seconds` | Store a float value | +| `cache_getfloat` | `key` | Retrieve a float value | +| `cache_setbytes` | `key, value, ttl_seconds` | Store a byte slice | +| `cache_getbytes` | `key` | Retrieve a byte slice | +| `cache_has` | `key` | Check if a key exists | +| `cache_remove` | `key` | Delete a cached value | + +**TTL (Time-to-Live):** Pass `0` to use the default TTL of 24 hours, or specify seconds. + +**Key Isolation:** Each plugin's cache keys are automatically namespaced, so different plugins can use the same key names without conflicts. + +#### Get Response Format + +Get operations return a JSON response: + +```json +{ + "value": "...", + "exists": true, + "error": "" +} +``` + +- `value`: The cached value (type matches the operation: string, int64, float64, or base64-encoded bytes) +- `exists`: `true` if the key was found and the type matched, `false` otherwise +- `error`: Error message if something went wrong + +#### Manifest Permissions + +Plugins using the cache must declare the permission in their manifest: + +```json +{ + "permissions": { + "cache": { + "reason": "Cache API responses to reduce external requests" + } + } +} +``` + +#### Example Cache Usage + +```go +package main + +import ( + "github.com/extism/go-pdk" +) + +// Import the generated cache SDK (from plugins/host/go/nd_host_cache.go) + +func fetchWithCache(key string) (string, error) { + // Try to get from cache first + resp, err := CacheGetString(key) + if err != nil { + return "", err + } + if resp.Exists { + return resp.Value, nil + } + + // Cache miss - fetch from external API + value := fetchFromAPI() + + // Cache for 1 hour (3600 seconds) + CacheSetString(key, value, 3600) + + return value, nil +} +``` + +To use the cache from your plugin, copy the generated SDK file `plugins/host/go/nd_host_cache.go` to your plugin directory. + ## Developing Plugins Plugins can be written in any language that compiles to WebAssembly. We recommend using the [Extism PDK](https://extism.org/docs/category/write-a-plug-in) for your language. diff --git a/plugins/host/cache.go b/plugins/host/cache.go new file mode 100644 index 000000000..37cb4da74 --- /dev/null +++ b/plugins/host/cache.go @@ -0,0 +1,117 @@ +package host + +import "context" + +// CacheService provides in-memory TTL-based caching capabilities for plugins. +// +// This service allows plugins to store and retrieve typed values (strings, integers, +// floats, and byte slices) with configurable time-to-live expiration. Each plugin's +// cache keys are automatically namespaced to prevent collisions between plugins. +// +// The cache is in-memory only and will be lost on server restart. Plugins should +// handle cache misses gracefully. +// +//nd:hostservice name=Cache permission=cache +type CacheService interface { + // SetString stores a string value in the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // - value: The string value to store + // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) + // + // Returns an error if the operation fails. + //nd:hostfunc + SetString(ctx context.Context, key string, value string, ttlSeconds int64) error + + // GetString retrieves a string value from the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns the value and whether the key exists. If the key doesn't exist + // or the stored value is not a string, exists will be false. + //nd:hostfunc + GetString(ctx context.Context, key string) (value string, exists bool, err error) + + // SetInt stores an integer value in the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // - value: The integer value to store + // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) + // + // Returns an error if the operation fails. + //nd:hostfunc + SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error + + // GetInt retrieves an integer value from the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns the value and whether the key exists. If the key doesn't exist + // or the stored value is not an integer, exists will be false. + //nd:hostfunc + GetInt(ctx context.Context, key string) (value int64, exists bool, err error) + + // SetFloat stores a float value in the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // - value: The float value to store + // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) + // + // Returns an error if the operation fails. + //nd:hostfunc + SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error + + // GetFloat retrieves a float value from the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns the value and whether the key exists. If the key doesn't exist + // or the stored value is not a float, exists will be false. + //nd:hostfunc + GetFloat(ctx context.Context, key string) (value float64, exists bool, err error) + + // SetBytes stores a byte slice in the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // - value: The byte slice to store + // - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) + // + // Returns an error if the operation fails. + //nd:hostfunc + SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error + + // GetBytes retrieves a byte slice from the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns the value and whether the key exists. If the key doesn't exist + // or the stored value is not a byte slice, exists will be false. + //nd:hostfunc + GetBytes(ctx context.Context, key string) (value []byte, exists bool, err error) + + // Has checks if a key exists in the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns true if the key exists and has not expired. + //nd:hostfunc + Has(ctx context.Context, key string) (exists bool, err error) + + // Remove deletes a value from the cache. + // + // Parameters: + // - key: The cache key (will be namespaced with plugin ID) + // + // Returns an error if the operation fails. Does not return an error if the key doesn't exist. + //nd:hostfunc + Remove(ctx context.Context, key string) error +} diff --git a/plugins/host/cache_gen.go b/plugins/host/cache_gen.go new file mode 100644 index 000000000..613c781c3 --- /dev/null +++ b/plugins/host/cache_gen.go @@ -0,0 +1,384 @@ +// Code generated by hostgen. DO NOT EDIT. + +package host + +import ( + "context" + "encoding/json" + + extism "github.com/extism/go-sdk" +) + +// CacheGetStringResponse is the response type for Cache.GetString. +type CacheGetStringResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetIntResponse is the response type for Cache.GetInt. +type CacheGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetFloatResponse is the response type for Cache.GetFloat. +type CacheGetFloatResponse struct { + Value float64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetBytesResponse is the response type for Cache.GetBytes. +type CacheGetBytesResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheHasResponse is the response type for Cache.Has. +type CacheHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// RegisterCacheHostFunctions registers Cache service host functions. +// The returned host functions should be added to the plugin's configuration. +func RegisterCacheHostFunctions(service CacheService) []extism.HostFunction { + return []extism.HostFunction{ + newCacheSetStringHostFunction(service), + newCacheGetStringHostFunction(service), + newCacheSetIntHostFunction(service), + newCacheGetIntHostFunction(service), + newCacheSetFloatHostFunction(service), + newCacheGetFloatHostFunction(service), + newCacheSetBytesHostFunction(service), + newCacheGetBytesHostFunction(service), + newCacheHasHostFunction(service), + newCacheRemoveHostFunction(service), + } +} + +func newCacheSetStringHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_setstring", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + value, err := p.ReadString(stack[1]) + if err != nil { + return + } + ttlSeconds := int64(stack[2]) + + // Call the service method + err = service.SetString(ctx, key, value, ttlSeconds) + if err != nil { + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } + return + } + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypePTR, extism.ValueTypeI64}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheGetStringHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_getstring", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + value, exists, err := service.GetString(ctx, key) + if err != nil { + cacheWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := CacheGetStringResponse{ + Value: value, + Exists: exists, + } + cacheWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheSetIntHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_setint", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + value := int64(stack[1]) + ttlSeconds := int64(stack[2]) + + // Call the service method + err = service.SetInt(ctx, key, value, ttlSeconds) + if err != nil { + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } + return + } + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeI64, extism.ValueTypeI64}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheGetIntHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_getint", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + value, exists, err := service.GetInt(ctx, key) + if err != nil { + cacheWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := CacheGetIntResponse{ + Value: value, + Exists: exists, + } + cacheWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheSetFloatHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_setfloat", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + value := extism.DecodeF64(stack[1]) + ttlSeconds := int64(stack[2]) + + // Call the service method + err = service.SetFloat(ctx, key, value, ttlSeconds) + if err != nil { + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } + return + } + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypeF64, extism.ValueTypeI64}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheGetFloatHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_getfloat", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + value, exists, err := service.GetFloat(ctx, key) + if err != nil { + cacheWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := CacheGetFloatResponse{ + Value: value, + Exists: exists, + } + cacheWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheSetBytesHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_setbytes", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + value, err := p.ReadBytes(stack[1]) + if err != nil { + return + } + ttlSeconds := int64(stack[2]) + + // Call the service method + err = service.SetBytes(ctx, key, value, ttlSeconds) + if err != nil { + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } + return + } + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } + }, + []extism.ValueType{extism.ValueTypePTR, extism.ValueTypePTR, extism.ValueTypeI64}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheGetBytesHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_getbytes", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + value, exists, err := service.GetBytes(ctx, key) + if err != nil { + cacheWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := CacheGetBytesResponse{ + Value: value, + Exists: exists, + } + cacheWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheHasHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_has", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + exists, err := service.Has(ctx, key) + if err != nil { + cacheWriteError(p, stack, err) + return + } + // Write JSON response to plugin memory + resp := CacheHasResponse{ + Exists: exists, + } + cacheWriteResponse(p, stack, resp) + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +func newCacheRemoveHostFunction(service CacheService) extism.HostFunction { + return extism.NewHostFunctionWithStack( + "cache_remove", + func(ctx context.Context, p *extism.CurrentPlugin, stack []uint64) { + // Read parameters from stack + key, err := p.ReadString(stack[0]) + if err != nil { + return + } + + // Call the service method + err = service.Remove(ctx, key) + if err != nil { + // Write error string to plugin memory + if ptr, err := p.WriteString(err.Error()); err == nil { + stack[0] = ptr + } + return + } + // Write empty string to indicate success + if ptr, err := p.WriteString(""); err == nil { + stack[0] = ptr + } + }, + []extism.ValueType{extism.ValueTypePTR}, + []extism.ValueType{extism.ValueTypePTR}, + ) +} + +// cacheWriteResponse writes a JSON response to plugin memory. +func cacheWriteResponse(p *extism.CurrentPlugin, stack []uint64, resp any) { + respBytes, err := json.Marshal(resp) + if err != nil { + cacheWriteError(p, stack, err) + return + } + respPtr, err := p.WriteBytes(respBytes) + if err != nil { + stack[0] = 0 + return + } + stack[0] = respPtr +} + +// cacheWriteError writes an error response to plugin memory. +func cacheWriteError(p *extism.CurrentPlugin, stack []uint64, err error) { + errResp := struct { + Error string `json:"error"` + }{Error: err.Error()} + respBytes, _ := json.Marshal(errResp) + respPtr, _ := p.WriteBytes(respBytes) + stack[0] = respPtr +} diff --git a/plugins/host/go/nd_host_cache.go b/plugins/host/go/nd_host_cache.go new file mode 100644 index 000000000..c9798f2b2 --- /dev/null +++ b/plugins/host/go/nd_host_cache.go @@ -0,0 +1,375 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Cache host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// cache_setstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setstring +func cache_setstring(uint64, uint64, int64) uint64 + +// cache_getstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getstring +func cache_getstring(uint64) uint64 + +// cache_setint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setint +func cache_setint(uint64, int64, int64) uint64 + +// cache_getint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getint +func cache_getint(uint64) uint64 + +// cache_setfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setfloat +func cache_setfloat(uint64, float64, int64) uint64 + +// cache_getfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getfloat +func cache_getfloat(uint64) uint64 + +// cache_setbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setbytes +func cache_setbytes(uint64, uint64, int64) uint64 + +// cache_getbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getbytes +func cache_getbytes(uint64) uint64 + +// cache_has is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_has +func cache_has(uint64) uint64 + +// cache_remove is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_remove +func cache_remove(uint64) uint64 + +// CacheGetStringResponse is the response type for Cache.GetString. +type CacheGetStringResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetIntResponse is the response type for Cache.GetInt. +type CacheGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetFloatResponse is the response type for Cache.GetFloat. +type CacheGetFloatResponse struct { + Value float64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetBytesResponse is the response type for Cache.GetBytes. +type CacheGetBytesResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheHasResponse is the response type for Cache.Has. +type CacheHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheSetString calls the cache_setstring host function. +// SetString stores a string value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The string value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetString(key string, value string, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateString(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setstring(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetString calls the cache_getstring host function. +// GetString retrieves a string value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a string, exists will be false. +func CacheGetString(key string) (*CacheGetStringResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getstring(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetStringResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetInt calls the cache_setint host function. +// SetInt stores an integer value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The integer value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetInt(key string, value int64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setint(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetInt calls the cache_getint host function. +// GetInt retrieves an integer value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not an integer, exists will be false. +func CacheGetInt(key string) (*CacheGetIntResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getint(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetIntResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetFloat calls the cache_setfloat host function. +// SetFloat stores a float value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The float value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetFloat(key string, value float64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setfloat(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetFloat calls the cache_getfloat host function. +// GetFloat retrieves a float value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a float, exists will be false. +func CacheGetFloat(key string) (*CacheGetFloatResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getfloat(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetFloatResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetBytes calls the cache_setbytes host function. +// SetBytes stores a byte slice in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The byte slice to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateBytes(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setbytes(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetBytes calls the cache_getbytes host function. +// GetBytes retrieves a byte slice from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a byte slice, exists will be false. +func CacheGetBytes(key string) (*CacheGetBytesResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getbytes(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetBytesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheHas calls the cache_has host function. +// Has checks if a key exists in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns true if the key exists and has not expired. +func CacheHas(key string) (*CacheHasResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_has(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheHasResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheRemove calls the cache_remove host function. +// Remove deletes a value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func CacheRemove(key string) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_remove(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} diff --git a/plugins/host/scheduler.go b/plugins/host/scheduler.go index 9a1fd018f..d640bc97b 100644 --- a/plugins/host/scheduler.go +++ b/plugins/host/scheduler.go @@ -41,9 +41,4 @@ type SchedulerService interface { // Returns an error if the schedule ID is not found or if cancellation fails. //nd:hostfunc CancelSchedule(ctx context.Context, scheduleID string) error - - // Close cleans up any resources used by the SchedulerService. - // - // This should be called when the plugin is unloaded to ensure proper cleanup. - Close() error } diff --git a/plugins/host/websocket.go b/plugins/host/websocket.go index f201f01cd..434a28a8a 100644 --- a/plugins/host/websocket.go +++ b/plugins/host/websocket.go @@ -56,10 +56,4 @@ type WebSocketService interface { // Returns an error if the connection is not found or if closing fails. //nd:hostfunc CloseConnection(ctx context.Context, connectionID string, code int32, reason string) error - - // Close cleans up any resources used by the WebSocketService. - // - // This should be called when the plugin is unloaded to ensure proper cleanup - // of all active WebSocket connections. - Close() error } diff --git a/plugins/host_cache.go b/plugins/host_cache.go new file mode 100644 index 000000000..b90d790cf --- /dev/null +++ b/plugins/host_cache.go @@ -0,0 +1,153 @@ +package plugins + +import ( + "context" + "time" + + "github.com/jellydator/ttlcache/v3" + "github.com/navidrome/navidrome/log" + "github.com/navidrome/navidrome/plugins/host" +) + +const ( + defaultCacheTTL = 24 * time.Hour +) + +// cacheServiceImpl implements the host.CacheService interface. +// Each plugin gets its own cache instance for isolation. +type cacheServiceImpl struct { + pluginName string + cache *ttlcache.Cache[string, any] + defaultTTL time.Duration +} + +// newCacheService creates a new cacheServiceImpl instance with its own cache. +func newCacheService(pluginName string) *cacheServiceImpl { + cache := ttlcache.New[string, any]( + ttlcache.WithTTL[string, any](defaultCacheTTL), + ) + // Start the janitor goroutine to clean up expired entries + go cache.Start() + + return &cacheServiceImpl{ + pluginName: pluginName, + cache: cache, + defaultTTL: defaultCacheTTL, + } +} + +// getTTL converts seconds to a duration, using default if 0 or negative +func (s *cacheServiceImpl) getTTL(seconds int64) time.Duration { + if seconds <= 0 { + return s.defaultTTL + } + return time.Duration(seconds) * time.Second +} + +// SetString stores a string value in the cache. +func (s *cacheServiceImpl) SetString(ctx context.Context, key string, value string, ttlSeconds int64) error { + s.cache.Set(key, value, s.getTTL(ttlSeconds)) + return nil +} + +// GetString retrieves a string value from the cache. +func (s *cacheServiceImpl) GetString(ctx context.Context, key string) (string, bool, error) { + item := s.cache.Get(key) + if item == nil { + return "", false, nil + } + + value, ok := item.Value().(string) + if !ok { + log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "string") + return "", false, nil + } + return value, true, nil +} + +// SetInt stores an integer value in the cache. +func (s *cacheServiceImpl) SetInt(ctx context.Context, key string, value int64, ttlSeconds int64) error { + s.cache.Set(key, value, s.getTTL(ttlSeconds)) + return nil +} + +// GetInt retrieves an integer value from the cache. +func (s *cacheServiceImpl) GetInt(ctx context.Context, key string) (int64, bool, error) { + item := s.cache.Get(key) + if item == nil { + return 0, false, nil + } + + value, ok := item.Value().(int64) + if !ok { + log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "int64") + return 0, false, nil + } + return value, true, nil +} + +// SetFloat stores a float value in the cache. +func (s *cacheServiceImpl) SetFloat(ctx context.Context, key string, value float64, ttlSeconds int64) error { + s.cache.Set(key, value, s.getTTL(ttlSeconds)) + return nil +} + +// GetFloat retrieves a float value from the cache. +func (s *cacheServiceImpl) GetFloat(ctx context.Context, key string) (float64, bool, error) { + item := s.cache.Get(key) + if item == nil { + return 0, false, nil + } + + value, ok := item.Value().(float64) + if !ok { + log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "float64") + return 0, false, nil + } + return value, true, nil +} + +// SetBytes stores a byte slice in the cache. +func (s *cacheServiceImpl) SetBytes(ctx context.Context, key string, value []byte, ttlSeconds int64) error { + s.cache.Set(key, value, s.getTTL(ttlSeconds)) + return nil +} + +// GetBytes retrieves a byte slice from the cache. +func (s *cacheServiceImpl) GetBytes(ctx context.Context, key string) ([]byte, bool, error) { + item := s.cache.Get(key) + if item == nil { + return nil, false, nil + } + + value, ok := item.Value().([]byte) + if !ok { + log.Debug(ctx, "Cache type mismatch", "plugin", s.pluginName, "key", key, "expected", "[]byte") + return nil, false, nil + } + return value, true, nil +} + +// Has checks if a key exists in the cache. +func (s *cacheServiceImpl) Has(ctx context.Context, key string) (bool, error) { + item := s.cache.Get(key) + return item != nil, nil +} + +// Remove deletes a value from the cache. +func (s *cacheServiceImpl) Remove(ctx context.Context, key string) error { + s.cache.Delete(key) + return nil +} + +// Close stops the cache's janitor goroutine and clears all entries. +// This is called when the plugin is unloaded. +func (s *cacheServiceImpl) Close() error { + s.cache.Stop() + s.cache.DeleteAll() + log.Debug("Closed plugin cache", "plugin", s.pluginName) + return nil +} + +// Ensure cacheServiceImpl implements host.CacheService +var _ host.CacheService = (*cacheServiceImpl)(nil) diff --git a/plugins/host_cache_test.go b/plugins/host_cache_test.go new file mode 100644 index 000000000..8bded1922 --- /dev/null +++ b/plugins/host_cache_test.go @@ -0,0 +1,555 @@ +//go:build !windows + +package plugins + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "time" + + "github.com/navidrome/navidrome/conf" + "github.com/navidrome/navidrome/conf/configtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("CacheService", func() { + var service *cacheServiceImpl + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + service = newCacheService("test_plugin") + }) + + AfterEach(func() { + if service != nil { + service.Close() + } + }) + + Describe("getTTL", func() { + It("returns default TTL when seconds is 0", func() { + ttl := service.getTTL(0) + Expect(ttl).To(Equal(defaultCacheTTL)) + }) + + It("returns default TTL when seconds is negative", func() { + ttl := service.getTTL(-10) + Expect(ttl).To(Equal(defaultCacheTTL)) + }) + + It("returns correct duration when seconds is positive", func() { + ttl := service.getTTL(60) + Expect(ttl).To(Equal(time.Minute)) + }) + }) + + Describe("Plugin Isolation", func() { + It("isolates keys between plugins", func() { + service1 := newCacheService("plugin1") + defer service1.Close() + service2 := newCacheService("plugin2") + defer service2.Close() + + // Both plugins set same key + err := service1.SetString(ctx, "shared", "value1", 0) + Expect(err).ToNot(HaveOccurred()) + err = service2.SetString(ctx, "shared", "value2", 0) + Expect(err).ToNot(HaveOccurred()) + + // Each plugin should get their own value + val1, exists1, err := service1.GetString(ctx, "shared") + Expect(err).ToNot(HaveOccurred()) + Expect(exists1).To(BeTrue()) + Expect(val1).To(Equal("value1")) + + val2, exists2, err := service2.GetString(ctx, "shared") + Expect(err).ToNot(HaveOccurred()) + Expect(exists2).To(BeTrue()) + Expect(val2).To(Equal("value2")) + }) + }) + + Describe("String Operations", func() { + It("sets and gets a string value", func() { + err := service.SetString(ctx, "string_key", "test_value", 300) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetString(ctx, "string_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal("test_value")) + }) + + It("returns not exists for missing key", func() { + value, exists, err := service.GetString(ctx, "missing_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(Equal("")) + }) + }) + + Describe("Integer Operations", func() { + It("sets and gets an integer value", func() { + err := service.SetInt(ctx, "int_key", 42, 300) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetInt(ctx, "int_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(int64(42))) + }) + + It("returns not exists for missing key", func() { + value, exists, err := service.GetInt(ctx, "missing_int_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(int64(0))) + }) + }) + + Describe("Float Operations", func() { + It("sets and gets a float value", func() { + err := service.SetFloat(ctx, "float_key", 3.14, 300) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetFloat(ctx, "float_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(3.14)) + }) + + It("returns not exists for missing key", func() { + value, exists, err := service.GetFloat(ctx, "missing_float_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(float64(0))) + }) + }) + + Describe("Bytes Operations", func() { + It("sets and gets a bytes value", func() { + byteData := []byte("hello world") + err := service.SetBytes(ctx, "bytes_key", byteData, 300) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetBytes(ctx, "bytes_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + Expect(value).To(Equal(byteData)) + }) + + It("returns not exists for missing key", func() { + value, exists, err := service.GetBytes(ctx, "missing_bytes_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(BeNil()) + }) + }) + + Describe("Type mismatch handling", func() { + It("returns not exists when type doesn't match the getter", func() { + // Set string + err := service.SetString(ctx, "mixed_key", "string value", 0) + Expect(err).ToNot(HaveOccurred()) + + // Try to get as int + value, exists, err := service.GetInt(ctx, "mixed_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(int64(0))) + }) + + It("returns not exists when getting string as float", func() { + err := service.SetString(ctx, "str_as_float", "not a float", 0) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetFloat(ctx, "str_as_float") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(Equal(float64(0))) + }) + + It("returns not exists when getting int as bytes", func() { + err := service.SetInt(ctx, "int_as_bytes", 123, 0) + Expect(err).ToNot(HaveOccurred()) + + value, exists, err := service.GetBytes(ctx, "int_as_bytes") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + Expect(value).To(BeNil()) + }) + }) + + Describe("Has Operation", func() { + It("returns true for existing key", func() { + err := service.SetString(ctx, "existing_key", "exists", 0) + Expect(err).ToNot(HaveOccurred()) + + exists, err := service.Has(ctx, "existing_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("returns false for non-existing key", func() { + exists, err := service.Has(ctx, "non_existing_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + }) + + Describe("Remove Operation", func() { + It("removes a value from the cache", func() { + // Set a value + err := service.SetString(ctx, "remove_key", "to be removed", 0) + Expect(err).ToNot(HaveOccurred()) + + // Verify it exists + exists, err := service.Has(ctx, "remove_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + + // Remove it + err = service.Remove(ctx, "remove_key") + Expect(err).ToNot(HaveOccurred()) + + // Verify it's gone + exists, err = service.Has(ctx, "remove_key") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeFalse()) + }) + + It("does not error when removing non-existing key", func() { + err := service.Remove(ctx, "never_existed") + Expect(err).ToNot(HaveOccurred()) + }) + }) + + Describe("TTL Behavior", func() { + It("uses default TTL when 0 is provided", func() { + err := service.SetString(ctx, "default_ttl", "value", 0) + Expect(err).ToNot(HaveOccurred()) + + // Value should exist immediately + exists, err := service.Has(ctx, "default_ttl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + + It("uses custom TTL when provided", func() { + err := service.SetString(ctx, "custom_ttl", "value", 300) + Expect(err).ToNot(HaveOccurred()) + + // Value should exist immediately + exists, err := service.Has(ctx, "custom_ttl") + Expect(err).ToNot(HaveOccurred()) + Expect(exists).To(BeTrue()) + }) + }) + + Describe("Close", func() { + It("removes all cache entries for the plugin", func() { + // Use a dedicated service for this test + closeService := newCacheService("close_test_plugin") + + // Set multiple values + err := closeService.SetString(ctx, "key1", "value1", 0) + Expect(err).ToNot(HaveOccurred()) + err = closeService.SetInt(ctx, "key2", 42, 0) + Expect(err).ToNot(HaveOccurred()) + err = closeService.SetFloat(ctx, "key3", 3.14, 0) + Expect(err).ToNot(HaveOccurred()) + + // Verify they exist + exists, _ := closeService.Has(ctx, "key1") + Expect(exists).To(BeTrue()) + exists, _ = closeService.Has(ctx, "key2") + Expect(exists).To(BeTrue()) + exists, _ = closeService.Has(ctx, "key3") + Expect(exists).To(BeTrue()) + + // Close the service + err = closeService.Close() + Expect(err).ToNot(HaveOccurred()) + + // All entries should be gone + exists, _ = closeService.Has(ctx, "key1") + Expect(exists).To(BeFalse()) + exists, _ = closeService.Has(ctx, "key2") + Expect(exists).To(BeFalse()) + exists, _ = closeService.Has(ctx, "key3") + Expect(exists).To(BeFalse()) + }) + + It("does not affect other plugins' cache entries", func() { + // Create two services for different plugins + service1 := newCacheService("plugin_close_test1") + service2 := newCacheService("plugin_close_test2") + defer service2.Close() + + // Set values for both plugins + err := service1.SetString(ctx, "key", "value1", 0) + Expect(err).ToNot(HaveOccurred()) + err = service2.SetString(ctx, "key", "value2", 0) + Expect(err).ToNot(HaveOccurred()) + + // Close only service1 + err = service1.Close() + Expect(err).ToNot(HaveOccurred()) + + // service1's key should be gone + exists, _ := service1.Has(ctx, "key") + Expect(exists).To(BeFalse()) + + // service2's key should still exist + exists, _ = service2.Has(ctx, "key") + Expect(exists).To(BeTrue()) + }) + }) +}) + +var _ = Describe("CacheService Integration", Ordered, func() { + var ( + manager *Manager + tmpDir string + ) + + BeforeAll(func() { + var err error + tmpDir, err = os.MkdirTemp("", "cache-test-*") + Expect(err).ToNot(HaveOccurred()) + + // Copy the fake_cache_plugin + srcPath := filepath.Join(testdataDir, "fake_cache_plugin.wasm") + destPath := filepath.Join(tmpDir, "fake_cache_plugin.wasm") + data, err := os.ReadFile(srcPath) + Expect(err).ToNot(HaveOccurred()) + err = os.WriteFile(destPath, data, 0600) + Expect(err).ToNot(HaveOccurred()) + + // Setup config + DeferCleanup(configtest.SetupConfig()) + conf.Server.Plugins.Enabled = true + conf.Server.Plugins.Folder = tmpDir + conf.Server.Plugins.AutoReload = false + conf.Server.CacheFolder = filepath.Join(tmpDir, "cache") + + // Create and start manager + manager = &Manager{ + plugins: make(map[string]*plugin), + } + err = manager.Start(GinkgoT().Context()) + Expect(err).ToNot(HaveOccurred()) + + DeferCleanup(func() { + _ = manager.Stop() + _ = os.RemoveAll(tmpDir) + }) + }) + + Describe("Plugin Loading", func() { + It("should load plugin with cache permission", func() { + manager.mu.RLock() + p, ok := manager.plugins["fake_cache_plugin"] + manager.mu.RUnlock() + Expect(ok).To(BeTrue()) + Expect(p.manifest.Permissions).ToNot(BeNil()) + Expect(p.manifest.Permissions.Cache).ToNot(BeNil()) + }) + }) + + Describe("Cache Operations via Plugin", func() { + type testCacheInput struct { + Operation string `json:"operation"` + Key string `json:"key"` + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + FloatVal float64 `json:"float_val,omitempty"` + BytesVal []byte `json:"bytes_val,omitempty"` + TTLSeconds int64 `json:"ttl_seconds,omitempty"` + } + type testCacheOutput struct { + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + FloatVal float64 `json:"float_val,omitempty"` + BytesVal []byte `json:"bytes_val,omitempty"` + Exists bool `json:"exists,omitempty"` + Error *string `json:"error,omitempty"` + } + + callTestCache := func(ctx context.Context, input testCacheInput) (*testCacheOutput, error) { + manager.mu.RLock() + p := manager.plugins["fake_cache_plugin"] + manager.mu.RUnlock() + + instance, err := p.instance() + if err != nil { + return nil, err + } + defer instance.Close(ctx) + + inputBytes, _ := json.Marshal(input) + _, outputBytes, err := instance.Call("nd_test_cache", inputBytes) + if err != nil { + return nil, err + } + + var output testCacheOutput + if err := json.Unmarshal(outputBytes, &output); err != nil { + return nil, err + } + if output.Error != nil { + return nil, errors.New(*output.Error) + } + return &output, nil + } + + It("should set and get string value", func() { + ctx := GinkgoT().Context() + + // Set string + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_string", + Key: "test_string", + StringVal: "hello world", + TTLSeconds: 300, + }) + Expect(err).ToNot(HaveOccurred()) + + // Get string + output, err := callTestCache(ctx, testCacheInput{ + Operation: "get_string", + Key: "test_string", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.StringVal).To(Equal("hello world")) + }) + + It("should set and get integer value", func() { + ctx := GinkgoT().Context() + + // Set int + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_int", + Key: "test_int", + IntVal: 42, + TTLSeconds: 300, + }) + Expect(err).ToNot(HaveOccurred()) + + // Get int + output, err := callTestCache(ctx, testCacheInput{ + Operation: "get_int", + Key: "test_int", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.IntVal).To(Equal(int64(42))) + }) + + It("should set and get float value", func() { + ctx := GinkgoT().Context() + + // Set float + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_float", + Key: "test_float", + FloatVal: 3.14159, + TTLSeconds: 300, + }) + Expect(err).ToNot(HaveOccurred()) + + // Get float + output, err := callTestCache(ctx, testCacheInput{ + Operation: "get_float", + Key: "test_float", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.FloatVal).To(Equal(3.14159)) + }) + + It("should set and get bytes value", func() { + ctx := GinkgoT().Context() + testBytes := []byte{0x01, 0x02, 0x03, 0x04} + + // Set bytes + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_bytes", + Key: "test_bytes", + BytesVal: testBytes, + TTLSeconds: 300, + }) + Expect(err).ToNot(HaveOccurred()) + + // Get bytes + output, err := callTestCache(ctx, testCacheInput{ + Operation: "get_bytes", + Key: "test_bytes", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + Expect(output.BytesVal).To(Equal(testBytes)) + }) + + It("should check if key exists", func() { + ctx := GinkgoT().Context() + + // Set a value + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_string", + Key: "exists_test", + StringVal: "value", + }) + Expect(err).ToNot(HaveOccurred()) + + // Check has + output, err := callTestCache(ctx, testCacheInput{ + Operation: "has", + Key: "exists_test", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeTrue()) + + // Check non-existent + output, err = callTestCache(ctx, testCacheInput{ + Operation: "has", + Key: "nonexistent", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + + It("should remove a key", func() { + ctx := GinkgoT().Context() + + // Set a value + _, err := callTestCache(ctx, testCacheInput{ + Operation: "set_string", + Key: "remove_test", + StringVal: "value", + }) + Expect(err).ToNot(HaveOccurred()) + + // Remove it + _, err = callTestCache(ctx, testCacheInput{ + Operation: "remove", + Key: "remove_test", + }) + Expect(err).ToNot(HaveOccurred()) + + // Verify it's gone + output, err := callTestCache(ctx, testCacheInput{ + Operation: "has", + Key: "remove_test", + }) + Expect(err).ToNot(HaveOccurred()) + Expect(output.Exists).To(BeFalse()) + }) + }) +}) diff --git a/plugins/host_scheduler.go b/plugins/host_scheduler.go index ffea6cdd0..0891d633f 100644 --- a/plugins/host_scheduler.go +++ b/plugins/host_scheduler.go @@ -50,7 +50,7 @@ type schedulerServiceImpl struct { } // newSchedulerService creates a new SchedulerService for a plugin. -func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) host.SchedulerService { +func newSchedulerService(pluginName string, manager *Manager, sched scheduler.Scheduler) *schedulerServiceImpl { return &schedulerServiceImpl{ pluginName: pluginName, manager: manager, diff --git a/plugins/host_websocket.go b/plugins/host_websocket.go index 78e4074fc..f9152c061 100644 --- a/plugins/host_websocket.go +++ b/plugins/host_websocket.go @@ -59,7 +59,7 @@ type webSocketServiceImpl struct { } // newWebSocketService creates a new WebSocketService for a plugin. -func newWebSocketService(pluginName string, manager *Manager, allowedHosts []string) host.WebSocketService { +func newWebSocketService(pluginName string, manager *Manager, allowedHosts []string) *webSocketServiceImpl { return &webSocketServiceImpl{ pluginName: pluginName, manager: manager, diff --git a/plugins/manager.go b/plugins/manager.go index 6a0ded160..98e0915c9 100644 --- a/plugins/manager.go +++ b/plugins/manager.go @@ -363,6 +363,7 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { stubHostFunctions = append(stubHostFunctions, host.RegisterSchedulerHostFunctions(nil)...) stubHostFunctions = append(stubHostFunctions, host.RegisterWebSocketHostFunctions(nil)...) stubHostFunctions = append(stubHostFunctions, host.RegisterArtworkHostFunctions(nil)...) + stubHostFunctions = append(stubHostFunctions, host.RegisterCacheHostFunctions(nil)...) // Create initial compiled plugin with stub host functions compiled, err := extism.NewCompiledPlugin(m.ctx, pluginManifest, extismConfig, stubHostFunctions) @@ -440,6 +441,13 @@ func (m *Manager) loadPlugin(name, wasmPath string) error { hostFunctions = append(hostFunctions, host.RegisterArtworkHostFunctions(service)...) } + // Register Cache host functions if permission is granted + if manifest.Permissions != nil && manifest.Permissions.Cache != nil { + service := newCacheService(name) + closers = append(closers, service) + hostFunctions = append(hostFunctions, host.RegisterCacheHostFunctions(service)...) + } + // Check if recompilation is needed (AllowedHosts or host functions) needsRecompile := len(pluginManifest.AllowedHosts) > 0 || len(hostFunctions) > 0 diff --git a/plugins/manifest.json b/plugins/manifest.json index 3969b814f..9208edd87 100644 --- a/plugins/manifest.json +++ b/plugins/manifest.json @@ -55,6 +55,9 @@ }, "artwork": { "$ref": "#/$defs/ArtworkPermission" + }, + "cache": { + "$ref": "#/$defs/CachePermission" } } }, @@ -69,6 +72,17 @@ } } }, + "CachePermission": { + "type": "object", + "description": "Cache service permissions for storing and retrieving data", + "additionalProperties": false, + "properties": { + "reason": { + "type": "string", + "description": "Explanation for why cache access is needed" + } + } + }, "HTTPPermission": { "type": "object", "description": "HTTP access permissions for a plugin", diff --git a/plugins/manifest_gen.go b/plugins/manifest_gen.go index 4f2d8625b..812c20375 100644 --- a/plugins/manifest_gen.go +++ b/plugins/manifest_gen.go @@ -11,6 +11,12 @@ type ArtworkPermission struct { Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` } +// Cache service permissions for storing and retrieving data +type CachePermission struct { + // Explanation for why cache access is needed + Reason *string `json:"reason,omitempty" yaml:"reason,omitempty" mapstructure:"reason,omitempty"` +} + // Configuration access permissions for a plugin type ConfigPermission struct { // Explanation for why config access is needed @@ -86,6 +92,9 @@ type Permissions struct { // Artwork corresponds to the JSON schema field "artwork". Artwork *ArtworkPermission `json:"artwork,omitempty" yaml:"artwork,omitempty" mapstructure:"artwork,omitempty"` + // Cache corresponds to the JSON schema field "cache". + Cache *CachePermission `json:"cache,omitempty" yaml:"cache,omitempty" mapstructure:"cache,omitempty"` + // Http corresponds to the JSON schema field "http". Http *HTTPPermission `json:"http,omitempty" yaml:"http,omitempty" mapstructure:"http,omitempty"` diff --git a/plugins/testdata/fake_cache_plugin/go.mod b/plugins/testdata/fake_cache_plugin/go.mod new file mode 100644 index 000000000..3663f3f94 --- /dev/null +++ b/plugins/testdata/fake_cache_plugin/go.mod @@ -0,0 +1,5 @@ +module fake-cache + +go 1.23 + +require github.com/extism/go-pdk v1.1.3 diff --git a/plugins/testdata/fake_cache_plugin/go.sum b/plugins/testdata/fake_cache_plugin/go.sum new file mode 100644 index 000000000..c15d38292 --- /dev/null +++ b/plugins/testdata/fake_cache_plugin/go.sum @@ -0,0 +1,2 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= diff --git a/plugins/testdata/fake_cache_plugin/main.go b/plugins/testdata/fake_cache_plugin/main.go new file mode 100644 index 000000000..ab860a41b --- /dev/null +++ b/plugins/testdata/fake_cache_plugin/main.go @@ -0,0 +1,210 @@ +// Fake Cache plugin for Navidrome plugin system integration tests. +// Build with: tinygo build -o ../fake_cache_plugin.wasm -target wasip1 -buildmode=c-shared . +package main + +import ( + "encoding/json" + + pdk "github.com/extism/go-pdk" +) + +// Manifest types +type Manifest struct { + Name string `json:"name"` + Author string `json:"author"` + Version string `json:"version"` + Description string `json:"description"` + Permissions *Permissions `json:"permissions,omitempty"` +} + +type Permissions struct { + Cache *CachePermission `json:"cache,omitempty"` +} + +type CachePermission struct { + Reason string `json:"reason,omitempty"` +} + +//go:wasmexport nd_manifest +func ndManifest() int32 { + manifest := Manifest{ + Name: "Fake Cache Plugin", + Author: "Navidrome Test", + Version: "1.0.0", + Description: "A fake cache plugin for integration testing", + Permissions: &Permissions{ + Cache: &CachePermission{ + Reason: "For testing cache operations", + }, + }, + } + out, err := json.Marshal(manifest) + if err != nil { + pdk.SetError(err) + return 1 + } + pdk.Output(out) + return 0 +} + +// TestCacheInput is the input for nd_test_cache callback. +type TestCacheInput struct { + Operation string `json:"operation"` // "set_string", "get_string", "set_int", "get_int", "set_float", "get_float", "set_bytes", "get_bytes", "has", "remove" + Key string `json:"key"` // Cache key + StringVal string `json:"string_val"` // For string operations + IntVal int64 `json:"int_val"` // For int operations + FloatVal float64 `json:"float_val"` // For float operations + BytesVal []byte `json:"bytes_val"` // For bytes operations + TTLSeconds int64 `json:"ttl_seconds"` // TTL in seconds +} + +// TestCacheOutput is the output from nd_test_cache callback. +type TestCacheOutput struct { + StringVal string `json:"string_val,omitempty"` + IntVal int64 `json:"int_val,omitempty"` + FloatVal float64 `json:"float_val,omitempty"` + BytesVal []byte `json:"bytes_val,omitempty"` + Exists bool `json:"exists,omitempty"` + Error *string `json:"error,omitempty"` +} + +// nd_test_cache is the test callback that tests the cache host functions. +// +//go:wasmexport nd_test_cache +func ndTestCache() int32 { + var input TestCacheInput + if err := pdk.InputJSON(&input); err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + + switch input.Operation { + case "set_string": + err := CacheSetString(input.Key, input.StringVal, input.TTLSeconds) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{}) + return 0 + + case "get_string": + resp, err := CacheGetString(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + if resp.Error != "" { + pdk.OutputJSON(TestCacheOutput{Error: &resp.Error}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{StringVal: resp.Value, Exists: resp.Exists}) + return 0 + + case "set_int": + err := CacheSetInt(input.Key, input.IntVal, input.TTLSeconds) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{}) + return 0 + + case "get_int": + resp, err := CacheGetInt(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + if resp.Error != "" { + pdk.OutputJSON(TestCacheOutput{Error: &resp.Error}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{IntVal: resp.Value, Exists: resp.Exists}) + return 0 + + case "set_float": + err := CacheSetFloat(input.Key, input.FloatVal, input.TTLSeconds) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{}) + return 0 + + case "get_float": + resp, err := CacheGetFloat(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + if resp.Error != "" { + pdk.OutputJSON(TestCacheOutput{Error: &resp.Error}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{FloatVal: resp.Value, Exists: resp.Exists}) + return 0 + + case "set_bytes": + err := CacheSetBytes(input.Key, input.BytesVal, input.TTLSeconds) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{}) + return 0 + + case "get_bytes": + resp, err := CacheGetBytes(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + if resp.Error != "" { + pdk.OutputJSON(TestCacheOutput{Error: &resp.Error}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{BytesVal: resp.Value, Exists: resp.Exists}) + return 0 + + case "has": + resp, err := CacheHas(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + if resp.Error != "" { + pdk.OutputJSON(TestCacheOutput{Error: &resp.Error}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{Exists: resp.Exists}) + return 0 + + case "remove": + err := CacheRemove(input.Key) + if err != nil { + errStr := err.Error() + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } + pdk.OutputJSON(TestCacheOutput{}) + return 0 + + default: + errStr := "unknown operation: " + input.Operation + pdk.OutputJSON(TestCacheOutput{Error: &errStr}) + return 0 + } +} + +func main() {} diff --git a/plugins/testdata/fake_cache_plugin/nd_host_cache.go b/plugins/testdata/fake_cache_plugin/nd_host_cache.go new file mode 100644 index 000000000..c9798f2b2 --- /dev/null +++ b/plugins/testdata/fake_cache_plugin/nd_host_cache.go @@ -0,0 +1,375 @@ +// Code generated by hostgen. DO NOT EDIT. +// +// This file contains client wrappers for the Cache host service. +// It is intended for use in Navidrome plugins built with TinyGo. +// +//go:build wasip1 + +package main + +import ( + "encoding/json" + "errors" + + "github.com/extism/go-pdk" +) + +// cache_setstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setstring +func cache_setstring(uint64, uint64, int64) uint64 + +// cache_getstring is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getstring +func cache_getstring(uint64) uint64 + +// cache_setint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setint +func cache_setint(uint64, int64, int64) uint64 + +// cache_getint is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getint +func cache_getint(uint64) uint64 + +// cache_setfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setfloat +func cache_setfloat(uint64, float64, int64) uint64 + +// cache_getfloat is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getfloat +func cache_getfloat(uint64) uint64 + +// cache_setbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_setbytes +func cache_setbytes(uint64, uint64, int64) uint64 + +// cache_getbytes is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_getbytes +func cache_getbytes(uint64) uint64 + +// cache_has is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_has +func cache_has(uint64) uint64 + +// cache_remove is the host function provided by Navidrome. +// +//go:wasmimport extism:host/user cache_remove +func cache_remove(uint64) uint64 + +// CacheGetStringResponse is the response type for Cache.GetString. +type CacheGetStringResponse struct { + Value string `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetIntResponse is the response type for Cache.GetInt. +type CacheGetIntResponse struct { + Value int64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetFloatResponse is the response type for Cache.GetFloat. +type CacheGetFloatResponse struct { + Value float64 `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheGetBytesResponse is the response type for Cache.GetBytes. +type CacheGetBytesResponse struct { + Value []byte `json:"value,omitempty"` + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheHasResponse is the response type for Cache.Has. +type CacheHasResponse struct { + Exists bool `json:"exists,omitempty"` + Error string `json:"error,omitempty"` +} + +// CacheSetString calls the cache_setstring host function. +// SetString stores a string value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The string value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetString(key string, value string, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateString(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setstring(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetString calls the cache_getstring host function. +// GetString retrieves a string value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a string, exists will be false. +func CacheGetString(key string) (*CacheGetStringResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getstring(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetStringResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetInt calls the cache_setint host function. +// SetInt stores an integer value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The integer value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetInt(key string, value int64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setint(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetInt calls the cache_getint host function. +// GetInt retrieves an integer value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not an integer, exists will be false. +func CacheGetInt(key string) (*CacheGetIntResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getint(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetIntResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetFloat calls the cache_setfloat host function. +// SetFloat stores a float value in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The float value to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetFloat(key string, value float64, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_setfloat(keyMem.Offset(), value, ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetFloat calls the cache_getfloat host function. +// GetFloat retrieves a float value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a float, exists will be false. +func CacheGetFloat(key string) (*CacheGetFloatResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getfloat(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetFloatResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheSetBytes calls the cache_setbytes host function. +// SetBytes stores a byte slice in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// - value: The byte slice to store +// - ttlSeconds: Time-to-live in seconds (0 uses default of 24 hours) +// +// Returns an error if the operation fails. +func CacheSetBytes(key string, value []byte, ttlSeconds int64) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + valueMem := pdk.AllocateBytes(value) + defer valueMem.Free() + + // Call the host function + responsePtr := cache_setbytes(keyMem.Offset(), valueMem.Offset(), ttlSeconds) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +} + +// CacheGetBytes calls the cache_getbytes host function. +// GetBytes retrieves a byte slice from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns the value and whether the key exists. If the key doesn't exist +// or the stored value is not a byte slice, exists will be false. +func CacheGetBytes(key string) (*CacheGetBytesResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_getbytes(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheGetBytesResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheHas calls the cache_has host function. +// Has checks if a key exists in the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns true if the key exists and has not expired. +func CacheHas(key string) (*CacheHasResponse, error) { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_has(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + responseBytes := responseMem.ReadBytes() + + // Parse the response + var response CacheHasResponse + if err := json.Unmarshal(responseBytes, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// CacheRemove calls the cache_remove host function. +// Remove deletes a value from the cache. +// +// Parameters: +// - key: The cache key (will be namespaced with plugin ID) +// +// Returns an error if the operation fails. Does not return an error if the key doesn't exist. +func CacheRemove(key string) error { + keyMem := pdk.AllocateString(key) + defer keyMem.Free() + + // Call the host function + responsePtr := cache_remove(keyMem.Offset()) + + // Read the response from memory + responseMem := pdk.FindMemory(responsePtr) + errStr := string(responseMem.ReadBytes()) + + if errStr != "" { + return errors.New(errStr) + } + + return nil +}