From 52313d0e3af95a7ac3f981f83a75adaf506f9808 Mon Sep 17 00:00:00 2001 From: Andrew Steurer <94206073+asteurer@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:34:12 -0500 Subject: [PATCH] feat(redis): implement redis in wasip2 Signed-off-by: Andrew Steurer <94206073+asteurer@users.noreply.github.com> --- v3/examples/mqtt-outbound/go.mod | 2 +- v3/examples/redis-outbound/README.md | 20 +++ v3/examples/redis-outbound/go.mod | 12 ++ v3/examples/redis-outbound/go.sum | 4 + v3/examples/redis-outbound/main.go | 155 ++++++++++++++++++ v3/examples/redis-outbound/spin.toml | 20 +++ v3/redis/redis.go | 232 +++++++++++++++++++++++++++ 7 files changed, 444 insertions(+), 1 deletion(-) create mode 100644 v3/examples/redis-outbound/README.md create mode 100644 v3/examples/redis-outbound/go.mod create mode 100644 v3/examples/redis-outbound/go.sum create mode 100644 v3/examples/redis-outbound/main.go create mode 100644 v3/examples/redis-outbound/spin.toml create mode 100644 v3/redis/redis.go diff --git a/v3/examples/mqtt-outbound/go.mod b/v3/examples/mqtt-outbound/go.mod index 1b69eb4..1aeb9a9 100644 --- a/v3/examples/mqtt-outbound/go.mod +++ b/v3/examples/mqtt-outbound/go.mod @@ -1,4 +1,4 @@ -module github.com/http_go +module github.com/spinframework/spin-go-sdk/v3/examples/mqtt-outbound go 1.24 diff --git a/v3/examples/redis-outbound/README.md b/v3/examples/redis-outbound/README.md new file mode 100644 index 0000000..f29a426 --- /dev/null +++ b/v3/examples/redis-outbound/README.md @@ -0,0 +1,20 @@ +# Requirements +- Latest version of [TinyGo](https://tinygo.org/getting-started/) +- Latest version of [Docker](https://docs.docker.com/get-started/get-docker/) + +# Usage + +In one terminal window, you'll run a Redis container: +```sh +docker run -p 6379:6379 redis:8.2 +``` + +In another terminal, you'll run your Spin app: +```sh +spin up --build +``` + +In yet another terminal, you'll interact with the Spin app: +```sh +curl localhost:3000 +``` \ No newline at end of file diff --git a/v3/examples/redis-outbound/go.mod b/v3/examples/redis-outbound/go.mod new file mode 100644 index 0000000..6754367 --- /dev/null +++ b/v3/examples/redis-outbound/go.mod @@ -0,0 +1,12 @@ +module github.com/spinframework/spin-go-sdk/v3/examples/redis-outbound + +go 1.24 + +require github.com/spinframework/spin-go-sdk/v3 v3.0.0 + +require ( + github.com/julienschmidt/httprouter v1.3.0 // indirect + go.bytecodealliance.org/cm v0.2.2 // indirect +) + +replace github.com/spinframework/spin-go-sdk/v3 => ../../ diff --git a/v3/examples/redis-outbound/go.sum b/v3/examples/redis-outbound/go.sum new file mode 100644 index 0000000..c1ebfdf --- /dev/null +++ b/v3/examples/redis-outbound/go.sum @@ -0,0 +1,4 @@ +github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +go.bytecodealliance.org/cm v0.2.2 h1:M9iHS6qs884mbQbIjtLX1OifgyPG9DuMs2iwz8G4WQA= +go.bytecodealliance.org/cm v0.2.2/go.mod h1:JD5vtVNZv7sBoQQkvBvAAVKJPhR/bqBH7yYXTItMfZI= diff --git a/v3/examples/redis-outbound/main.go b/v3/examples/redis-outbound/main.go new file mode 100644 index 0000000..3c0a431 --- /dev/null +++ b/v3/examples/redis-outbound/main.go @@ -0,0 +1,155 @@ +package main + +import ( + "fmt" + "net/http" + "os" + "reflect" + "sort" + "strconv" + + spin_http "github.com/spinframework/spin-go-sdk/v3/http" + "github.com/spinframework/spin-go-sdk/v3/redis" +) + +func init() { + + // handler for the http trigger + spin_http.Handle(func(w http.ResponseWriter, _ *http.Request) { + + // addr is the environment variable set in `spin.toml` that points to the + // address of the Redis server. + addr := os.Getenv("REDIS_ADDRESS") + + // channel is the environment variable set in `spin.toml` that specifies + // the Redis channel that the component will publish to. + channel := os.Getenv("REDIS_CHANNEL") + + // payload is the data publish to the redis channel. + payload := []byte(`Hello redis from tinygo!`) + + rdb, err := redis.NewClient(addr) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if err := rdb.Publish(channel, payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // set redis `mykey` = `myvalue` + if err := rdb.Set("mykey", []byte("myvalue")); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + // get redis payload for `mykey` + if payload, err := rdb.Get("mykey"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else { + w.Write([]byte("mykey value was: ")) + w.Write(payload) + w.Write([]byte("\n")) + } + + // incr `spin-go-incr` by 1 + if payload, err := rdb.Incr("spin-go-incr"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else { + w.Write([]byte("spin-go-incr value: ")) + w.Write([]byte(strconv.FormatInt(payload, 10))) + w.Write([]byte("\n")) + } + + // delete `spin-go-incr` and `mykey` + if payload, err := rdb.Del("spin-go-incr", "mykey", "non-existing-key"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } else { + w.Write([]byte("deleted keys num: ")) + w.Write([]byte(strconv.FormatInt(int64(payload), 10))) + w.Write([]byte("\n")) + } + + if _, err := rdb.Sadd("myset", "foo", "bar"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + { + expected := []string{"bar", "foo"} + payload, err := rdb.Smembers("myset") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + sort.Strings(payload) + if !reflect.DeepEqual(payload, expected) { + http.Error( + w, + fmt.Sprintf( + "unexpected SMEMBERS result: expected %v, got %v", + expected, + payload, + ), + http.StatusInternalServerError, + ) + return + } + } + + if _, err := rdb.Srem("myset", "bar"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + { + expected := []string{"foo"} + if payload, err := rdb.Smembers("myset"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if !reflect.DeepEqual(payload, expected) { + http.Error( + w, + fmt.Sprintf( + "unexpected SMEMBERS result: expected %v, got %v", + expected, + payload, + ), + http.StatusInternalServerError, + ) + return + } + } + + if _, err := rdb.Execute("set", "message", "hello"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if _, err := rdb.Execute("append", "message", " world"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if payload, err := rdb.Execute("get", "message"); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } else if !reflect.DeepEqual( + payload, + []*redis.Result{{ + Kind: redis.ResultKindBinary, + Val: []byte("hello world"), + }}) { + + http.Error(w, "unexpected GET result", http.StatusInternalServerError) + fmt.Println() + return + } + }) +} + +func main() {} diff --git a/v3/examples/redis-outbound/spin.toml b/v3/examples/redis-outbound/spin.toml new file mode 100644 index 0000000..9f34b37 --- /dev/null +++ b/v3/examples/redis-outbound/spin.toml @@ -0,0 +1,20 @@ +spin_manifest_version = 2 + +[application] +name = "go-redis-outbound-example" +version = "0.1.0" +authors = ["Andrew Steurer <94206073+asteurer@users.noreply.github.com>"] +description = "Using Spin with Redis" + +[[trigger.http]] +route = "/" +component = "redis-outbound" + +[component.redis-outbound] +source = "main.wasm" +environment = { REDIS_ADDRESS = "redis://localhost:6379", REDIS_CHANNEL = "messages" } +allowed_outbound_hosts = ["redis://localhost:6379"] + +[component.redis-outbound.build] +command = "tinygo build -target=wasip2 --wit-package $(go list -mod=readonly -m -f '{{.Dir}}' github.com/spinframework/spin-go-sdk/v3)/wit --wit-world http-trigger -gc=leaking -o main.wasm main.go" +watch = ["**/*.go", "go.mod"] diff --git a/v3/redis/redis.go b/v3/redis/redis.go new file mode 100644 index 0000000..0d6ce01 --- /dev/null +++ b/v3/redis/redis.go @@ -0,0 +1,232 @@ +// Package redis provides the handler function for the Redis trigger, as well +// as access to Redis within Spin components. + +package redis + +import ( + "errors" + "fmt" + + "github.com/spinframework/spin-go-sdk/v3/internal/fermyon/spin/v2.0.0/redis" + "go.bytecodealliance.org/cm" +) + +// Client is a Redis client. +type Client struct { + conn redis.Connection +} + +// NewClient returns a Redis client. +func NewClient(address string) (Client, error) { + conn, err, isErr := redis.ConnectionOpen(address).Result() + if isErr { + return Client{}, toError(err) + } + + return Client{conn: conn}, nil +} + +// Publish a Redis message to the specified channel. +func (c *Client) Publish(channel string, payload []byte) error { + _, err, isErr := c.conn.Publish(channel, redis.Payload(cm.ToList(payload))).Result() + if isErr { + return toError(err) + } + + return nil +} + +// Get the value of a key. +func (c *Client) Get(key string) ([]byte, error) { + payload, err, isErr := c.conn.Get(key).Result() + if isErr { + return nil, toError(err) + } + + if payload.None() { + return nil, nil + } + + return payload.Some().Slice(), nil +} + +// Set key to value. +// +// If key already holds a value, it is overwritten. +func (c *Client) Set(key string, payload []byte) error { + _, err, isErr := c.conn.Set(key, redis.Payload(cm.ToList(payload))).Result() + if isErr { + return toError(err) + } + + return nil +} + +// Increments the number stored at key by one. +// +// If the key does not exist, it is set to 0 before performing the operation. +// An `error::type-error` is returned if the key contains a value of the wrong type +// or contains a string that can not be represented as integer. +func (c *Client) Incr(key string) (int64, error) { + incrementedNum, err, isErr := c.conn.Incr(key).Result() + if isErr { + return 0, toError(err) + } + + return incrementedNum, nil +} + +// Removes the specified keys. +// +// A key is ignored if it does not exist. Returns the number of keys deleted. +func (c *Client) Del(keys ...string) (uint32, error) { + numKeysDeleted, err, isErr := c.conn.Del(cm.ToList(keys)).Result() + if isErr { + return 0, toError(err) + } + + return numKeysDeleted, nil +} + +// Add the specified `values` to the set named `key`, returning the number of newly-added values. +func (c *Client) Sadd(key string, values ...string) (uint32, error) { + numValuesAdded, err, isErr := c.conn.Sadd(key, cm.ToList(values)).Result() + if isErr { + return 0, toError(err) + } + + return numValuesAdded, nil +} + +// Retrieve the contents of the set named `key`. +func (c *Client) Smembers(key string) ([]string, error) { + setValues, err, isErr := c.conn.Smembers(key).Result() + if isErr { + return nil, toError(err) + } + + return setValues.Slice(), nil +} + +// Remove the specified `values` from the set named `key`, returning the number of newly-removed values. +func (c *Client) Srem(key string, values ...string) (uint32, error) { + valuesRemoved, err, isErr := c.conn.Srem(key, cm.ToList(values)).Result() + if isErr { + return 0, toError(err) + } + + return valuesRemoved, nil +} + +// ResultKind represents a result type returned from executing a Redis command. +type ResultKind uint8 + +const ( + ResultKindNil ResultKind = iota + ResultKindStatus + ResultKindInt64 + ResultKindBinary +) + +// String implements fmt.Stringer. +func (r ResultKind) String() string { + switch r { + case ResultKindNil: + return "nil" + case ResultKindStatus: + return "status" + case ResultKindInt64: + return "int64" + case ResultKindBinary: + return "binary" + default: + return "unknown" + } +} + +// GoString implements fmt.GoStringer. +func (r ResultKind) GoString() string { return r.String() } + +// Result represents a value returned from a Redis command. +type Result struct { + Kind ResultKind + Val any +} + +// Execute runs the specified Redis command with the specified arguments, +// returning zero or more results. This is a general-purpose function which +// should work with any Redis command. +// +// Arguments must be string, []byte, int, int64, or int32. +func (c *Client) Execute(command string, arguments ...any) ([]*Result, error) { + var params []redis.RedisParameter + for _, a := range arguments { + p, err := createParameter(a) + if err != nil { + return nil, err + } + params = append(params, p) + } + + redisResults, err, isErr := c.conn.Execute(command, cm.ToList(params)).Result() + if isErr { + return nil, toError(err) + } + + var results []*Result + for _, r := range redisResults.Slice() { + results = append(results, toResult(r)) + } + + return results, nil +} + +func createParameter(x any) (redis.RedisParameter, error) { + switch v := x.(type) { + case int: + return redis.RedisParameterInt64(int64(v)), nil + case int64: + return redis.RedisParameterInt64(v), nil + case int32: + return redis.RedisParameterInt64(int64(v)), nil + case []byte: + return redis.RedisParameterBinary(redis.Payload(cm.ToList(v))), nil + case string: + return redis.RedisParameterBinary(redis.Payload(cm.ToList([]byte(v)))), nil + default: + return redis.RedisParameter{}, fmt.Errorf("invalid type %T; must be string, []byte, int, int64, or int32", v) + } +} + +func toResult(param redis.RedisResult) *Result { + switch { + case param.Status() != nil: + return &Result{ + Kind: ResultKindStatus, + Val: param.Status(), + } + case param.Int64() != nil: + return &Result{ + Kind: ResultKindInt64, + Val: param.Int64(), + } + case param.Binary() != nil: + return &Result{ + Kind: ResultKindBinary, + Val: param.Binary().Slice(), + } + default: + return &Result{ + Kind: ResultKindNil, + Val: param.Nil(), + } + } +} + +func toError(e redis.Error) error { + if e.String() == "other" { + return fmt.Errorf(*e.Other()) + } + + return errors.New(e.String()) +}