feat(storage): define FileStore interface and refactor to use it

Extract a FileStore interface from the concrete *storage.Storage MinIO
wrapper so the API layer is storage-backend agnostic.

- Define FileStore interface in internal/storage/interface.go
- Add Exists method to MinIO Storage (via StatObject)
- Add compile-time interface satisfaction check
- Change Server.storage and ServerState.storage to FileStore interface
- Update NewServer and NewServerState signatures
- Add Backend and FilesystemConfig fields to StorageConfig
- Add backend selection switch in main.go (minio/filesystem/unknown)
- Update config.example.yaml with backend field

The nil-interface pattern is preserved: when storage is unconfigured,
store remains a true nil FileStore (not a typed nil pointer), so all
existing if s.storage == nil checks continue to work correctly.

Closes #126
This commit is contained in:
2026-02-17 11:49:35 -06:00
parent 906277149e
commit b531617e39
7 changed files with 91 additions and 28 deletions

View File

@@ -37,7 +37,7 @@ type Server struct {
schemas map[string]*schema.Schema
schemasDir string
partgen *partnum.Generator
storage *storage.Storage
storage storage.FileStore
auth *auth.Service
sessions *scs.SessionManager
oidc *auth.OIDCBackend
@@ -61,7 +61,7 @@ func NewServer(
database *db.DB,
schemas map[string]*schema.Schema,
schemasDir string,
store *storage.Storage,
store storage.FileStore,
authService *auth.Service,
sessionManager *scs.SessionManager,
oidcBackend *auth.OIDCBackend,

View File

@@ -26,13 +26,13 @@ type ServerState struct {
mu sync.RWMutex
readOnly bool
storageOK bool
storage *storage.Storage
storage storage.FileStore
broker *Broker
done chan struct{}
}
// NewServerState creates a new server state tracker.
func NewServerState(logger zerolog.Logger, store *storage.Storage, broker *Broker) *ServerState {
func NewServerState(logger zerolog.Logger, store storage.FileStore, broker *Broker) *ServerState {
return &ServerState{
logger: logger.With().Str("component", "server-state").Logger(),
storageOK: store != nil, // assume healthy if configured

View File

@@ -109,14 +109,21 @@ type DatabaseConfig struct {
MaxConnections int `yaml:"max_connections"`
}
// StorageConfig holds MinIO connection settings.
// StorageConfig holds object storage settings.
type StorageConfig struct {
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"use_ssl"`
Region string `yaml:"region"`
Backend string `yaml:"backend"` // "minio" (default) or "filesystem"
Endpoint string `yaml:"endpoint"`
AccessKey string `yaml:"access_key"`
SecretKey string `yaml:"secret_key"`
Bucket string `yaml:"bucket"`
UseSSL bool `yaml:"use_ssl"`
Region string `yaml:"region"`
Filesystem FilesystemConfig `yaml:"filesystem"`
}
// FilesystemConfig holds local filesystem storage settings.
type FilesystemConfig struct {
RootDir string `yaml:"root_dir"`
}
// SchemasConfig holds schema loading settings.

View File

@@ -0,0 +1,21 @@
// Package storage defines the FileStore interface and backend implementations.
package storage
import (
"context"
"io"
"net/url"
"time"
)
// FileStore is the interface for file storage backends.
type FileStore interface {
Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) (*PutResult, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
GetVersion(ctx context.Context, key string, versionID string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
Exists(ctx context.Context, key string) (bool, error)
Copy(ctx context.Context, srcKey, dstKey string) error
PresignPut(ctx context.Context, key string, expiry time.Duration) (*url.URL, error)
Ping(ctx context.Context) error
}

View File

@@ -1,4 +1,3 @@
// Package storage provides MinIO file storage operations.
package storage
import (
@@ -22,6 +21,9 @@ type Config struct {
Region string
}
// Compile-time check: *Storage implements FileStore.
var _ FileStore = (*Storage)(nil)
// Storage wraps MinIO client operations.
type Storage struct {
client *minio.Client
@@ -112,6 +114,19 @@ func (s *Storage) Delete(ctx context.Context, key string) error {
return nil
}
// Exists checks if an object exists in storage.
func (s *Storage) Exists(ctx context.Context, key string) (bool, error) {
_, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
if err != nil {
resp := minio.ToErrorResponse(err)
if resp.Code == "NoSuchKey" {
return false, nil
}
return false, fmt.Errorf("checking object existence: %w", err)
}
return true, nil
}
// Ping checks if the storage backend is reachable by verifying the bucket exists.
func (s *Storage) Ping(ctx context.Context) error {
_, err := s.client.BucketExists(ctx, s.bucket)