feat(boundaries): add PreviewStore port and filesystem adapter
- commit
- ca9fb1419c920316617348d60f9fe17a60503b82
- author
- brice · · 2026-03-19 04:45 UTC
- committer
- brice · · 2026-03-19 05:13 UTC
- parent
- d757b58
- signature
- unsigned
3 files · +139 -0
A internal/adapters/out/filesystem/previewstore.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "errors" | |
| 7 | + | "io/fs" | |
| 8 | + | "os" | |
| 9 | + | "sync" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | type previewStoreData struct { | |
| 15 | + | Previews []domain.PreviewRoute `json:"previews"` | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // PreviewStore persists preview routes to a JSON file. | |
| 19 | + | type PreviewStore struct { | |
| 20 | + | path string | |
| 21 | + | mu sync.Mutex | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // NewPreviewStore creates a new filesystem-backed preview store. | |
| 25 | + | func NewPreviewStore(path string) *PreviewStore { | |
| 26 | + | return &PreviewStore{path: path} | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) { | |
| 30 | + | s.mu.Lock() | |
| 31 | + | defer s.mu.Unlock() | |
| 32 | + | ||
| 33 | + | data, err := os.ReadFile(s.path) | |
| 34 | + | if err != nil { | |
| 35 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 36 | + | return nil, nil | |
| 37 | + | } | |
| 38 | + | return nil, err | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var store previewStoreData | |
| 42 | + | if err := json.Unmarshal(data, &store); err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | return store.Previews, nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error { | |
| 49 | + | s.mu.Lock() | |
| 50 | + | defer s.mu.Unlock() | |
| 51 | + | ||
| 52 | + | if len(previews) == 0 { | |
| 53 | + | err := os.Remove(s.path) | |
| 54 | + | if errors.Is(err, fs.ErrNotExist) { | |
| 55 | + | return nil | |
| 56 | + | } | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ") | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | return os.WriteFile(s.path, data, 0o644) | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "errors" | |||
| 7 | + | "io/fs" | |||
| 8 | + | "os" | |||
| 9 | + | "sync" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/domain" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | type previewStoreData struct { | |||
| 15 | + | Previews []domain.PreviewRoute `json:"previews"` | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // PreviewStore persists preview routes to a JSON file. | |||
| 19 | + | type PreviewStore struct { | |||
| 20 | + | path string | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | // NewPreviewStore creates a new filesystem-backed preview store. | |||
| 25 | + | func NewPreviewStore(path string) *PreviewStore { | |||
| 26 | + | return &PreviewStore{path: path} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (s *PreviewStore) Load(_ context.Context) ([]domain.PreviewRoute, error) { | |||
| 30 | + | s.mu.Lock() | |||
| 31 | + | defer s.mu.Unlock() | |||
| 32 | + | ||||
| 33 | + | data, err := os.ReadFile(s.path) | |||
| 34 | + | if err != nil { | |||
| 35 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 36 | + | return nil, nil | |||
| 37 | + | } | |||
| 38 | + | return nil, err | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var store previewStoreData | |||
| 42 | + | if err := json.Unmarshal(data, &store); err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 45 | + | return store.Previews, nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) error { | |||
| 49 | + | s.mu.Lock() | |||
| 50 | + | defer s.mu.Unlock() | |||
| 51 | + | ||||
| 52 | + | if len(previews) == 0 { | |||
| 53 | + | err := os.Remove(s.path) | |||
| 54 | + | if errors.Is(err, fs.ErrNotExist) { | |||
| 55 | + | return nil | |||
| 56 | + | } | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | data, err := json.MarshalIndent(previewStoreData{Previews: previews}, "", " ") | |||
| 61 | + | if err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | return os.WriteFile(s.path, data, 0o644) | |||
| 65 | + | } | |||
A internal/adapters/out/filesystem/previewstore_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package filesystem | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestPreviewStore_SaveAndLoad(t *testing.T) { | |
| 16 | + | dir := t.TempDir() | |
| 17 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |
| 18 | + | ||
| 19 | + | previews := []domain.PreviewRoute{ | |
| 20 | + | { | |
| 21 | + | Domain: "myapp--feat.example.com", | |
| 22 | + | Image: "myapp:preview-feat", | |
| 23 | + | BaseRoute: "myapp.example.com", | |
| 24 | + | Name: "feat", | |
| 25 | + | CreatedAt: time.Now().Truncate(time.Second), | |
| 26 | + | ExpiresAt: time.Now().Add(48 * time.Hour).Truncate(time.Second), | |
| 27 | + | HTTPS: true, | |
| 28 | + | Volumes: []string{"preview-feat-pgdata"}, | |
| 29 | + | Containers: []string{"preview-feat-postgres"}, | |
| 30 | + | }, | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | ctx := context.Background() | |
| 34 | + | err := store.Save(ctx, previews) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | ||
| 37 | + | loaded, err := store.Load(ctx) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | assert.Equal(t, previews, loaded) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestPreviewStore_LoadEmpty(t *testing.T) { | |
| 43 | + | dir := t.TempDir() | |
| 44 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |
| 45 | + | ||
| 46 | + | loaded, err := store.Load(context.Background()) | |
| 47 | + | require.NoError(t, err) | |
| 48 | + | assert.Empty(t, loaded) | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestPreviewStore_SaveEmpty(t *testing.T) { | |
| 52 | + | dir := t.TempDir() | |
| 53 | + | path := filepath.Join(dir, "previews.json") | |
| 54 | + | store := NewPreviewStore(path) | |
| 55 | + | ||
| 56 | + | err := store.Save(context.Background(), []domain.PreviewRoute{}) | |
| 57 | + | require.NoError(t, err) | |
| 58 | + | ||
| 59 | + | _, err = os.Stat(path) | |
| 60 | + | assert.True(t, os.IsNotExist(err)) | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package filesystem | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/gordon/internal/domain" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestPreviewStore_SaveAndLoad(t *testing.T) { | |||
| 16 | + | dir := t.TempDir() | |||
| 17 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |||
| 18 | + | ||||
| 19 | + | previews := []domain.PreviewRoute{ | |||
| 20 | + | { | |||
| 21 | + | Domain: "myapp--feat.example.com", | |||
| 22 | + | Image: "myapp:preview-feat", | |||
| 23 | + | BaseRoute: "myapp.example.com", | |||
| 24 | + | Name: "feat", | |||
| 25 | + | CreatedAt: time.Now().Truncate(time.Second), | |||
| 26 | + | ExpiresAt: time.Now().Add(48 * time.Hour).Truncate(time.Second), | |||
| 27 | + | HTTPS: true, | |||
| 28 | + | Volumes: []string{"preview-feat-pgdata"}, | |||
| 29 | + | Containers: []string{"preview-feat-postgres"}, | |||
| 30 | + | }, | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | ctx := context.Background() | |||
| 34 | + | err := store.Save(ctx, previews) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | ||||
| 37 | + | loaded, err := store.Load(ctx) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | assert.Equal(t, previews, loaded) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestPreviewStore_LoadEmpty(t *testing.T) { | |||
| 43 | + | dir := t.TempDir() | |||
| 44 | + | store := NewPreviewStore(filepath.Join(dir, "previews.json")) | |||
| 45 | + | ||||
| 46 | + | loaded, err := store.Load(context.Background()) | |||
| 47 | + | require.NoError(t, err) | |||
| 48 | + | assert.Empty(t, loaded) | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func TestPreviewStore_SaveEmpty(t *testing.T) { | |||
| 52 | + | dir := t.TempDir() | |||
| 53 | + | path := filepath.Join(dir, "previews.json") | |||
| 54 | + | store := NewPreviewStore(path) | |||
| 55 | + | ||||
| 56 | + | err := store.Save(context.Background(), []domain.PreviewRoute{}) | |||
| 57 | + | require.NoError(t, err) | |||
| 58 | + | ||||
| 59 | + | _, err = os.Stat(path) | |||
| 60 | + | assert.True(t, os.IsNotExist(err)) | |||
| 61 | + | } | |||
A internal/boundaries/out/previewstore.go +13 -0
| @@ -0,0 +1,13 @@ | |||
| 1 | + | package out | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // PreviewStore persists active preview routes. | |
| 10 | + | type PreviewStore interface { | |
| 11 | + | Load(ctx context.Context) ([]domain.PreviewRoute, error) | |
| 12 | + | Save(ctx context.Context, previews []domain.PreviewRoute) error | |
| 13 | + | } | |
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package out | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // PreviewStore persists active preview routes. | |||
| 10 | + | type PreviewStore interface { | |||
| 11 | + | Load(ctx context.Context) ([]domain.PreviewRoute, error) | |||
| 12 | + | Save(ctx context.Context, previews []domain.PreviewRoute) error | |||
| 13 | + | } | |||