Repository: mr-linch/go-tg
Pull request #99 (2022-08-08 16:23:37)

feat(session): add StoreFile

codecov[bot]: # [Codecov](https://codecov.io/gh/mr-linch/go-tg/pull/99?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk) Report
> Merging [#99](https://codecov.io/gh/mr-linch/go-tg/pull/99?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk) (472b915) into [main](https://codecov.io/gh/mr-linch/go-tg/commit/5b4c6fa312bd1626b2206aca63d0933f711ec0f9?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk) (5b4c6fa) will **decrease** coverage by `0.41%`.
> The diff coverage is `75.00%`.

```diff
@@            Coverage Diff             @@
##             main      #99      +/-   ##
==========================================
- Coverage   92.30%   91.88%   -0.42%     
==========================================
  Files          19       20       +1     
  Lines        2261     2317      +56     
==========================================
+ Hits         2087     2129      +42     
- Misses        141      150       +9     
- Partials       33       38       +5     
```


| [Impacted Files](https://codecov.io/gh/mr-linch/go-tg/pull/99?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk) | Coverage Δ | |
|---|---|---|
| [tgb/session/manager.go](https://codecov.io/gh/mr-linch/go-tg/pull/99/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk#diff-dGdiL3Nlc3Npb24vbWFuYWdlci5nbw==) | `94.54% <ø> (ø)` | |
| [tgb/webhook.go](https://codecov.io/gh/mr-linch/go-tg/pull/99/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk#diff-dGdiL3dlYmhvb2suZ28=) | `91.06% <ø> (ø)` | |
| [tgb/session/store\_file.go](https://codecov.io/gh/mr-linch/go-tg/pull/99/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk#diff-dGdiL3Nlc3Npb24vc3RvcmVfZmlsZS5nbw==) | `75.00% <75.00%> (ø)` | |

:mega: Codecov can now indicate which changes are the most critical in Pull Requests. [Learn more](https://about.codecov.io/product/feature/runtime-insights/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Sasha+Savchuk)


Patch:
diff --git a/Makefile b/Makefile
index e2dd2e1..0c92915 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,3 @@
-
 .PHONY: go-tidy
 go-tidy: ## tidy all go modules
 	$(call print-target)
diff --git a/README.md b/README.md
index 087e150..74fbb79 100644
--- a/README.md
+++ b/README.md
@@ -659,12 +659,8 @@ By default, the session is serialized using [`encoding/json`](https://pkg.go.dev
 
 Session store is simple key-value storage.
 Where key is a string value unique for each chat and value is serialized session data.
-By default, manager use [in-memory store](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session#StoreMemory) implementation.
-Also, library provides modules for different backends:
-
-- [sessionbolt](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session/sessionbolt#Store) - boltdb-based store;
-- [sessionredis](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session/sessionredis#Store) - redis-based store;
-- [sessionsql](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session/sessionsql#Store) - sql-based store (SQLite3, MySQL, PostgreSQL);
+By default, manager use [`StoreMemory`](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session#StoreMemory) implementation.
+Also package has [`StoreFile`](https://pkg.go.dev/github.com/mr-linch/go-tg/tgb/session#StoreFile) based on FS.
 
 #### How to use sessions?
 
diff --git a/examples/session-filter/main.go b/examples/session-filter/main.go
index 5576ad4..18237f4 100644
--- a/examples/session-filter/main.go
+++ b/examples/session-filter/main.go
@@ -43,7 +43,9 @@ func main() {
 	// create session manager with default session value
 	sessionManager := session.NewManager(Session{
 		Step: SessionStepInit,
-	})
+	}, session.WithStore(
+		session.NewStoreFile("sessions"),
+	))
 
 	isSessionStep := func(state SessionStep) tgb.Filter {
 		return sessionManager.Filter(func(session *Session) bool {
diff --git a/tgb/session/manager.go b/tgb/session/manager.go
index 8085cdc..315b1b0 100644
--- a/tgb/session/manager.go
+++ b/tgb/session/manager.go
@@ -9,7 +9,7 @@
 //
 // By default, the data is stored in memory (see [StoreMemory]).
 // It's not good, because it's not persistent and you can lose data if the bot is restarted.
-// You can use any provided storage, see subpackages of current package for list.
+// You can use any provided storage, see [StoreMemory] and [StoreFile] for built-in stores.
 // Or you can use your own storage by implementing [Store] interface.
 //
 // # How it works?
@@ -189,15 +189,15 @@ func (manager *Manager[T]) Reset(session *T) {
 //
 // Example:
 //
-//  isStepName := manager.Filter(func(session *Session) bool {
-//    return session.Step == "name"
-//  })
+//	isStepName := manager.Filter(func(session *Session) bool {
+//	  return session.Step == "name"
+//	})
 //
 // This filter can be used in [github.com/mr-linch/go-tg/tgb.Router] handler registration method:
 //
-//  router.Message(func(ctx context.Context, mu *tgb.MessageUpdate) error {
-//    // ...
-//  }, isStepName)
+//	router.Message(func(ctx context.Context, mu *tgb.MessageUpdate) error {
+//	  // ...
+//	}, isStepName)
 func (manager *Manager[T]) Filter(fn func(*T) bool) tgb.Filter {
 	return tgb.FilterFunc(func(ctx context.Context, update *tgb.Update) (bool, error) {
 		session := manager.Get(ctx)
@@ -213,10 +213,10 @@ func (manager *Manager[T]) Filter(fn func(*T) bool) tgb.Filter {
 // Wrap allow use manager as [github.com/mr-linch/go-tg/tgb.Middleware].
 //
 // This middleware do following:
-//   1. fetch session data from [Store] or create new one if it doesn't exist.
-//   2. put session data to [context.Context]
-//   3. call handler (note: if chain returns error, return an error and do not save changes)
-//   4. update session data in [Store] if it was changed (delete if session value equals initial)
+//  1. fetch session data from [Store] or create new one if it doesn't exist.
+//  2. put session data to [context.Context]
+//  3. call handler (note: if chain returns error, return an error and do not save changes)
+//  4. update session data in [Store] if it was changed (delete if session value equals initial)
 func (manager *Manager[T]) Wrap(next tgb.Handler) tgb.Handler {
 	return tgb.HandlerFunc(func(ctx context.Context, update *tgb.Update) error {
 		key := manager.keyFunc(update)
diff --git a/tgb/session/store_file.go b/tgb/session/store_file.go
new file mode 100644
index 0000000..09eae3c
--- /dev/null
+++ b/tgb/session/store_file.go
@@ -0,0 +1,106 @@
+package session
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"path/filepath"
+)
+
+// StoreFile is a session store that stores sessions in files.
+type StoreFile struct {
+	dir       string
+	perms     os.FileMode
+	transform func(string) []string
+}
+
+var _ Store = (*StoreFile)(nil)
+
+// StoreFileOption is a function that can be passed to NewStoreFile
+// to customize the behavior of the store.
+type StoreFileOption func(*StoreFile)
+
+// WithStoreFilePerm sets the permissions of the files created by the store.
+func WithStoreFilePerm(perms os.FileMode) StoreFileOption {
+	return func(store *StoreFile) {
+		store.perms = perms
+	}
+}
+
+// WithStoreFileTransform sets the transform function that is used to
+func WithStoreFileTransform(transform func(string) []string) StoreFileOption {
+	return func(store *StoreFile) {
+		store.transform = transform
+	}
+}
+
+// NewStoreFile creates a new StoreFile.
+func NewStoreFile(dir string, opts ...StoreFileOption) *StoreFile {
+	store := &StoreFile{
+		dir:   dir,
+		perms: 0666,
+		transform: func(key string) []string {
+			return []string{key}
+		},
+	}
+
+	for _, opt := range opts {
+		opt(store)
+	}
+
+	return store
+}
+
+func (store *StoreFile) getSessionPath(key string) string {
+	paths := append([]string{store.dir}, store.transform(key)...)
+
+	return filepath.Join(paths...) + ".session"
+}
+
+// Set stores the session data.
+func (store *StoreFile) Set(ctx context.Context, key string, value []byte) error {
+	path := store.getSessionPath(key)
+
+	if err := store.ensureDirExists(path); err != nil {
+		return fmt.Errorf("create dir if not exists: %w", err)
+	}
+
+	if err := os.WriteFile(path, value, store.perms); err != nil {
+		return fmt.Errorf("write file: %w", err)
+	}
+
+	return nil
+}
+
+func (store *StoreFile) ensureDirExists(filePath string) error {
+	parent := filepath.Dir(filePath)
+
+	return os.MkdirAll(parent, os.ModePerm)
+}
+
+// Get retrieves the session data.
+func (store *StoreFile) Get(ctx context.Context, key string) ([]byte, error) {
+	path := store.getSessionPath(key)
+
+	data, err := os.ReadFile(path)
+	if os.IsNotExist(err) {
+		return nil, nil
+	} else if err != nil {
+		return nil, fmt.Errorf("read file: %w", err)
+	}
+
+	return data, nil
+}
+
+// Del deletes the session data.
+func (store *StoreFile) Del(ctx context.Context, key string) error {
+	path := store.getSessionPath(key)
+
+	if err := os.Remove(path); os.IsNotExist(err) {
+		return nil
+	} else if err != nil {
+		return fmt.Errorf("remove file: %w", err)
+	}
+
+	return nil
+}
diff --git a/tgb/webhook.go b/tgb/webhook.go
index 80140cf..613e1eb 100644
--- a/tgb/webhook.go
+++ b/tgb/webhook.go
@@ -18,7 +18,7 @@ import (
 
 // Webhook is a Telegram bot webhook handler.
 // It handles incoming webhook requests and calls the handler function.
-// It implements the http.Handler interface, but can be adapted to any other handlers, see [Webhook.ServeRequest]
+// It implements the http.Handler interface, but can be adapted to any other handlers, see [Webhook.ServeRequest].
 type Webhook struct {
 	url     string
 	handler Handler


Test patch:
diff --git a/tgb/session/store_file_test.go b/tgb/session/store_file_test.go
new file mode 100644
index 0000000..ddda408
--- /dev/null
+++ b/tgb/session/store_file_test.go
@@ -0,0 +1,68 @@
+package session
+
+import (
+	"context"
+	"io/ioutil"
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestStoreFile_New(t *testing.T) {
+	dir, err := os.MkdirTemp("", "session-store-file-test")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	t.Run("Default", func(t *testing.T) {
+		store := NewStoreFile(dir)
+
+		assert.Equal(t, dir, store.dir)
+		assert.Equal(t, os.FileMode(0666), store.perms)
+		assert.Equal(t, []string{"abc"}, store.transform("abc"))
+	})
+
+	t.Run("Custom", func(t *testing.T) {
+		store := NewStoreFile(dir,
+			WithStoreFilePerm(os.FileMode(0644)),
+			WithStoreFileTransform(func(key string) []string {
+				return strings.Split(key, "_")
+			}),
+		)
+
+		assert.Equal(t, dir, store.dir)
+		assert.Equal(t, os.FileMode(0644), store.perms)
+		assert.Equal(t, []string{"a", "b"}, store.transform("a_b"))
+	})
+}
+
+func TestStoreFile_Set(t *testing.T) {
+	dir, err := os.MkdirTemp("", "session-store-file-test")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	store := NewStoreFile(dir, WithStoreFileTransform(func(key string) []string {
+		return strings.Split(key, "_")
+	}))
+
+	err = store.Set(context.Background(), "k_e_y", []byte("value"))
+	assert.NoError(t, err)
+
+	f, err := os.Open(filepath.Join(dir, "k", "e", "y.session"))
+	assert.NoError(t, err)
+	defer f.Close()
+
+	b, err := ioutil.ReadAll(f)
+	assert.NoError(t, err)
+	assert.Equal(t, []byte("value"), b)
+}
+
+func TestFileStore_Generic(t *testing.T) {
+	dir, err := os.MkdirTemp("", "session-store-file-test")
+	assert.NoError(t, err)
+	defer os.RemoveAll(dir)
+
+	genericStoreTest(t, NewStoreFile(dir))
+}
diff --git a/tgb/session/store_memory_test.go b/tgb/session/store_memory_test.go
index 18908cb..8ba6fcf 100644
--- a/tgb/session/store_memory_test.go
+++ b/tgb/session/store_memory_test.go
@@ -7,8 +7,8 @@ import (
 	"github.com/stretchr/testify/require"
 )
 
-func TestStore(t *testing.T) {
-	store := NewStoreMemory()
+func genericStoreTest(t *testing.T, store Store) {
+	t.Helper()
 
 	err := store.Set(context.Background(), "key", []byte("value"))
 	require.NoError(t, err)
@@ -24,3 +24,9 @@ func TestStore(t *testing.T) {
 	require.NoError(t, err)
 	require.Nil(t, v)
 }
+
+func TestStore(t *testing.T) {
+	store := NewStoreMemory()
+
+	genericStoreTest(t, store)
+}