Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions yafsm/constants.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package yafsm

const (
stateKey = "state"
stateDataKey = "stateData"
)
77 changes: 77 additions & 0 deletions yafsm/entityfsm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package yafsm

import (
"context"

"github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors"
)

// EntityFSMStorage is a wrapper over FSM to work with specific entity (user, chat, etc).
type EntityFSMStorage struct {
storage FSM
uid string
}

// NewUserFSMStorage creates a new EntityFSMStorage for a specific user ID.
//
// Example usage:
//
// userFSMStorage := NewUserFSMStorage(fsmStorage, "user123")
func NewUserFSMStorage(
storage FSM,
uid string,
) *EntityFSMStorage {
return &EntityFSMStorage{
storage: storage,
uid: uid,
}
}

// SetState sets the state for the entity.
//
// Example usage:
//
// err := userFSMStorage.SetState(ctx, &SomeState{Field: "value"})
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) SetState(
ctx context.Context,
stateData State,
) yaerrors.Error {
return b.storage.SetState(ctx, b.uid, stateData)
}

// GetState retrieves the current state and its data for the entity.
//
// Example usage:
//
// stateName, stateData, err := userFSMStorage.GetState(ctx)
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) GetState(
ctx context.Context,
) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive
return b.storage.GetState(ctx, b.uid)
}

// GetStateData unmarshals the state data into the provided empty state struct.
//
// Example usage:
//
// var stateData SomeState
//
// err := userFSMStorage.GetStateData(marshalledData, &stateData)
//
// if err != nil {
// // handle error
// }
func (b *EntityFSMStorage) GetStateData(
stateData stateDataMarshalled,
emptyState State,
) yaerrors.Error {
return b.storage.GetStateData(stateData, emptyState)
}
196 changes: 196 additions & 0 deletions yafsm/fsm.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
package yafsm

import (
"context"
"encoding/json"
"net/http"
"reflect"

"github.com/YaCodeDev/GoYaCodeDevUtils/yacache"
"github.com/YaCodeDev/GoYaCodeDevUtils/yaerrors"
)

// State is an interface that all states must implement.
type State interface {
StateName() string
}

// BaseState provides a default implementation of the State interface.
type BaseState[T State] struct{}

// StateName returns the name of the state type.
func (BaseState[T]) StateName() string {
var zero T

t := reflect.TypeOf(zero)
if t.Kind() == reflect.Pointer {
t = t.Elem()
}

return t.Name()
}

// Empty state is implementation of State interface with no data.
type EmptyState struct {
BaseState[EmptyState]
}

// stateDataMarshalled is a type alias for marshalled state data.
type stateDataMarshalled string

// StateAndData is a struct that holds the state name and its marshalled data.
type StateAndData struct {
State string `json:"state"`
StateData string `json:"stateData"`
}

// FSM is an interface for finite state machine storage.\
type FSM interface {
SetState(ctx context.Context, uid string, state State) yaerrors.Error
GetState(ctx context.Context, uid string) (string, stateDataMarshalled, yaerrors.Error)
GetStateData(stateData stateDataMarshalled, emptyState State) yaerrors.Error
}

// DefaultFSMStorage is a default implementation of the FSM interface using yacache.
type DefaultFSMStorage[T yacache.Container] struct {
storage yacache.Cache[T]
defaultState State
}

// NewDefaultFSMStorage creates a new instance of DefaultFSMStorage.
//
// Example usage:
//
// cache := yacache.NewCache(redisClient)
//
// fsmStorage := fsm.NewDefaultFSMStorage(cache, fsm.EmptyState{})
func NewDefaultFSMStorage[T yacache.Container](
storage yacache.Cache[T],
defaultState State,
) *DefaultFSMStorage[T] {
return &DefaultFSMStorage[T]{
storage: storage,
defaultState: defaultState,
}
}

// SetState sets the state for a given user ID.
// The state data is marshalled to JSON before being stored.
//
// Example usage:
//
// err := fsmStorage.SetState(ctx, "123", &SomeState{Field: "value"})
func (b *DefaultFSMStorage[T]) SetState(
ctx context.Context,
uid string,
stateData State,
) yaerrors.Error {
val, err := json.Marshal(stateData)
if err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to marshal state data",
)
}

val, err = json.Marshal(StateAndData{
State: stateData.StateName(),
StateData: string(val),
})
if err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to marshal state data",
)
}

return b.storage.Set(ctx, uid, string(val), 0)
}

// GetState retrieves the current state and its marshalled data for a given user ID.
// If no state is found, it returns the default state.
//
// Example usage:
//
// stateName, stateData, err := fsmStorage.GetState(ctx, "123")
func (b *DefaultFSMStorage[T]) GetState(
ctx context.Context,
uid string,
) (string, stateDataMarshalled, yaerrors.Error) { // nolint: revive
data, err := b.storage.Get(ctx, uid)
if err != nil {
return b.defaultState.StateName(), "", nil
}

var stateAndData map[string]string

if err := json.Unmarshal([]byte(data), &stateAndData); err != nil {
return "", "", yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data map",
)
}

state, ok := stateAndData[stateKey]

if !ok {
return "", "", yaerrors.FromString(
http.StatusNotFound,
"failed to get state",
)
}

return state, stateDataMarshalled(data), nil
}

// GetStateData unmarshals the state data into the provided empty state struct.
//
// Example usage:
//
// var stateData SomeState
//
// err := fsmStorage.GetStateData(marshalledData, &stateData)
//
// if err != nil {
// // handle error
// }
func (b *DefaultFSMStorage[T]) GetStateData(
stateData stateDataMarshalled,
emptyState State,
) yaerrors.Error {
if stateData == "" {
return nil
}

var stateAndData map[string]string

if err := json.Unmarshal([]byte(stateData), &stateAndData); err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data map",
)
}

stateDataMarshalled, ok := stateAndData[stateDataKey]

if !ok {
return yaerrors.FromString(
http.StatusNotFound,
"failed to get state data",
)
}

if err := json.Unmarshal([]byte(stateDataMarshalled), emptyState); err != nil {
return yaerrors.FromError(
http.StatusInternalServerError,
err,
"failed to unmarshal state data",
)
}

return nil
}
101 changes: 101 additions & 0 deletions yafsm/fsm_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package yafsm_test

import (
"context"
"encoding/json"
"errors"
"testing"

"github.com/YaCodeDev/GoYaCodeDevUtils/yacache"
"github.com/YaCodeDev/GoYaCodeDevUtils/yafsm"
)

type ExampleState struct {
yafsm.BaseState[ExampleState]

Param string `json:"param"`
}

func TestFSMStorage_SetGetRoundTrip(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())

fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "12345"
wantParam := "exampleparam"

// 1) set
if err := fsm.SetState(ctx, uid, ExampleState{Param: wantParam}); err != nil {
t.Fatalf("SetState failed: %v", err)
}

// 2) get state name + raw payload
stateName, raw, err := fsm.GetState(ctx, uid)
if err != nil {
t.Fatalf("GetState failed: %v", err)
}

if stateName != (ExampleState{}).StateName() {
t.Fatalf("unexpected state name: want %q, got %q",
(ExampleState{}).StateName(), stateName)
}

// 3) unmarshal into struct
var got ExampleState
if err := fsm.GetStateData(raw, &got); err != nil {
t.Fatalf("GetStateData failed: %v", err)
}

if got.Param != wantParam {
t.Fatalf("unexpected param: want %q, got %q", wantParam, got.Param)
}
}

func TestFSMStorage_DefaultStateReturned(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())
fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "non-existent"

name, raw, err := fsm.GetState(ctx, uid)
if err != nil {
t.Fatalf("GetState failed: %v", err)
}

if name != (yafsm.EmptyState{}).StateName() {
t.Fatalf("expected default state name %q, got %q",
(yafsm.EmptyState{}).StateName(), name)
}

if raw != "" {
t.Fatalf("expected empty raw data, got %q", raw)
}
}

func TestFSMStorage_CorruptedPayload(t *testing.T) {
ctx := context.Background()

cache := yacache.NewCache(yacache.NewMemoryContainer())
fsm := yafsm.NewDefaultFSMStorage(cache, yafsm.EmptyState{})

uid := "bad:user"

err := cache.Set(ctx, uid, "{not:a:json}", 0)
if err != nil {
t.Fatalf("failed to set corrupted data: %v", err)
}

_, _, err = fsm.GetState(ctx, uid)
if err == nil {
t.Fatal("expected error on corrupted JSON, got nil")
}

var syntaxErr *json.SyntaxError
if !errors.As(err, &syntaxErr) {
t.Fatalf("expected json.SyntaxError, got %v", err)
}
}
Loading