Files
ResolveSpec/pkg/server/staticweb/plan.md

593 lines
18 KiB
Markdown

# StaticWeb Package Interface-Driven Refactoring Plan
## Overview
Refactor `pkg/server/staticweb` to be interface-driven, router-agnostic, and maintainable. This is a breaking change that replaces the existing API with a cleaner design.
## User Requirements
- ✅ Work with any server (not just Gorilla mux)
- ✅ Serve static files from zip files or directories
- ✅ Support embedded, local filesystems
- ✅ Interface-driven and maintainable architecture
- ✅ Struct-based configuration
- ✅ Breaking changes acceptable
- 🔮 Remote HTTP/HTTPS and S3 support (future feature)
## Design Principles
1. **Interface-first**: Define clear interfaces for all abstractions
2. **Composition over inheritance**: Combine small, focused components
3. **Router-agnostic**: Return standard `http.Handler` for universal compatibility
4. **Configurable policies**: Extract hardcoded behavior into pluggable strategies
5. **Resource safety**: Proper lifecycle management with `Close()` methods
6. **Testability**: Mock-friendly interfaces with clear boundaries
7. **Extensibility**: Easy to add new providers (HTTP, S3, etc.) in future
## Architecture Overview
### Core Interfaces (pkg/server/staticweb/interfaces.go)
```go
// FileSystemProvider abstracts file sources (local, zip, embedded, future: http, s3)
type FileSystemProvider interface {
Open(name string) (fs.File, error)
Close() error
Type() string
}
// CachePolicy defines caching behavior
type CachePolicy interface {
GetCacheTime(path string) int
GetCacheHeaders(path string) map[string]string
}
// MIMETypeResolver determines content types
type MIMETypeResolver interface {
GetMIMEType(path string) string
RegisterMIMEType(extension, mimeType string)
}
// FallbackStrategy handles missing files
type FallbackStrategy interface {
ShouldFallback(path string) bool
GetFallbackPath(path string) string
}
// StaticFileService manages mount points
type StaticFileService interface {
Mount(config MountConfig) error
Unmount(urlPrefix string) error
ListMounts() []string
Reload() error
Close() error
Handler() http.Handler // Router-agnostic integration
}
```
### Configuration (pkg/server/staticweb/config.go)
```go
// MountConfig configures a single mount point
type MountConfig struct {
URLPrefix string
Provider FileSystemProvider
CachePolicy CachePolicy // Optional, uses default if nil
MIMEResolver MIMETypeResolver // Optional, uses default if nil
FallbackStrategy FallbackStrategy // Optional, no fallback if nil
}
// ServiceConfig configures the service
type ServiceConfig struct {
DefaultCacheTime int // Default: 48 hours
DefaultMIMETypes map[string]string // Additional MIME types
}
```
## Implementation Plan
### Step 1: Create Core Interfaces
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/interfaces.go` (NEW)
Define all interfaces listed above. This establishes the contract for all components.
### Step 2: Implement Default Policies
**Directory**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/policies/` (NEW)
**File**: `policies/cache.go`
- `SimpleCachePolicy` - Single TTL for all files
- `ExtensionBasedCachePolicy` - Different TTL per file extension
- `NoCachePolicy` - Disables caching
**File**: `policies/mime.go`
- `DefaultMIMEResolver` - Standard web MIME types + stdlib
- `ConfigurableMIMEResolver` - User-defined mappings
- Migrate hardcoded MIME types from `InitMimeTypes()` (lines 60-76)
**File**: `policies/fallback.go`
- `NoFallback` - Returns 404 for missing files
- `HTMLFallbackStrategy` - SPA routing (serves index.html)
- `ExtensionBasedFallback` - Current behavior (checks extensions)
- Migrate logic from `StaticHTMLFallbackHandler()` (lines 241-285)
### Step 3: Implement FileSystem Providers
**Directory**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/` (NEW)
**File**: `providers/local.go`
```go
type LocalFSProvider struct {
path string
fs fs.FS
}
func NewLocalFSProvider(path string) (*LocalFSProvider, error)
func (l *LocalFSProvider) Open(name string) (fs.File, error)
func (l *LocalFSProvider) Close() error
func (l *LocalFSProvider) Type() string
```
**File**: `providers/zip.go`
```go
type ZipFSProvider struct {
zipPath string
zipReader *zip.ReadCloser
zipFS *zipfs.ZipFS
mu sync.RWMutex
}
func NewZipFSProvider(zipPath string) (*ZipFSProvider, error)
func (z *ZipFSProvider) Open(name string) (fs.File, error)
func (z *ZipFSProvider) Close() error
func (z *ZipFSProvider) Type() string
```
- Integrates with existing `pkg/server/zipfs/zipfs.go`
- Manages zip file lifecycle properly
**File**: `providers/embed.go`
```go
type EmbedFSProvider struct {
embedFS *embed.FS
zipFile string // Optional: path within embedded FS to zip file
zipReader *zip.ReadCloser
fs fs.FS
mu sync.RWMutex
}
func NewEmbedFSProvider(embedFS *embed.FS, zipFile string) (*EmbedFSProvider, error)
func (e *EmbedFSProvider) Open(name string) (fs.File, error)
func (e *EmbedFSProvider) Close() error
func (e *EmbedFSProvider) Type() string
```
### Step 4: Implement Mount Point
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/mount.go` (NEW)
```go
type mountPoint struct {
urlPrefix string
provider FileSystemProvider
cachePolicy CachePolicy
mimeResolver MIMETypeResolver
fallbackStrategy FallbackStrategy
}
func newMountPoint(config MountConfig, defaults *ServiceConfig) (*mountPoint, error)
func (m *mountPoint) ServeHTTP(w http.ResponseWriter, r *http.Request)
func (m *mountPoint) Close() error
```
**Key behaviors**:
- Strips URL prefix before passing to provider
- Applies cache headers via `CachePolicy`
- Sets Content-Type via `MIMETypeResolver`
- Falls back via `FallbackStrategy` if file not found
- Integrates with `http.FileServer()` for actual serving
### Step 5: Implement Service
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service.go` (NEW)
```go
type service struct {
mounts map[string]*mountPoint // urlPrefix -> mount
config *ServiceConfig
mu sync.RWMutex
}
func NewService(config *ServiceConfig) StaticFileService
func (s *service) Mount(config MountConfig) error
func (s *service) Unmount(urlPrefix string) error
func (s *service) ListMounts() []string
func (s *service) Reload() error
func (s *service) Close() error
func (s *service) Handler() http.Handler
```
**Handler Implementation**:
- Performs longest-prefix matching to find mount point
- Delegates to mount point's `ServeHTTP()`
- Returns silently if no match (allows API routes to handle)
- Thread-safe with `sync.RWMutex`
### Step 6: Create Configuration Helpers
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/config.go` (NEW)
```go
// Default configurations
func DefaultServiceConfig() *ServiceConfig
func DefaultCachePolicy() CachePolicy
func DefaultMIMEResolver() MIMETypeResolver
// Helper constructors
func LocalProvider(path string) FileSystemProvider
func ZipProvider(zipPath string) FileSystemProvider
func EmbedProvider(embedFS *embed.FS, zipFile string) FileSystemProvider
// Policy constructors
func SimpleCache(seconds int) CachePolicy
func ExtensionCache(rules map[string]int) CachePolicy
func HTMLFallback(indexFile string) FallbackStrategy
func ExtensionFallback(staticExtensions []string) FallbackStrategy
```
### Step 7: Update/Remove Existing Files
**REMOVE**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/staticweb.go`
- All functionality migrated to new interface-based design
- No backward compatibility needed per user request
**KEEP**: `/home/hein/hein/dev/ResolveSpec/pkg/server/zipfs/zipfs.go`
- Still used by `ZipFSProvider`
- Already implements `fs.FS` interface correctly
### Step 8: Create Examples and Tests
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/example_test.go` (NEW)
```go
func ExampleService_basic() { /* Serve local directory */ }
func ExampleService_spa() { /* SPA with fallback */ }
func ExampleService_multiple() { /* Multiple mount points */ }
func ExampleService_zip() { /* Serve from zip file */ }
func ExampleService_embedded() { /* Serve from embedded zip */ }
```
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/service_test.go` (NEW)
- Test mount/unmount operations
- Test longest-prefix matching
- Test concurrent access
- Test resource cleanup
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/providers/providers_test.go` (NEW)
- Test each provider implementation
- Test resource cleanup
- Test error handling
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/testing/mocks.go` (NEW)
- `MockFileSystemProvider` - In-memory file storage
- `MockCachePolicy` - Configurable cache behavior
- `MockMIMEResolver` - Custom MIME mappings
- Test helpers for common scenarios
### Step 9: Create Documentation
**File**: `/home/hein/hein/dev/ResolveSpec/pkg/server/staticweb/README.md` (NEW)
Document:
- Quick start examples
- Interface overview
- Provider implementations
- Policy customization
- Router integration patterns
- Migration guide from old API
- Future features roadmap
## Key Improvements Over Current Implementation
### 1. Router-Agnostic Design
**Before**: Coupled to Gorilla mux via `RegisterRoutes(*mux.Router)`
**After**: Returns `http.Handler`, works with any router
```go
// Works with Gorilla Mux
muxRouter.PathPrefix("/").Handler(service.Handler())
// Works with standard http.ServeMux
http.Handle("/", service.Handler())
// Works with any http.Handler-compatible router
```
### 2. Configurable Behaviors
**Before**: Hardcoded MIME types, cache times, file extensions
**After**: Pluggable policies
```go
// Custom cache per file type
cachePolicy := ExtensionCache(map[string]int{
".html": 3600, // 1 hour
".js": 86400, // 1 day
".css": 86400, // 1 day
".png": 604800, // 1 week
})
// Custom fallback logic
fallback := HTMLFallback("index.html")
service.Mount(MountConfig{
URLPrefix: "/",
Provider: LocalProvider("./dist"),
CachePolicy: cachePolicy,
FallbackStrategy: fallback,
})
```
### 3. Better Resource Management
**Before**: Manual zip file cleanup, easy to leak resources
**After**: Proper lifecycle with `Close()` on all components
```go
defer service.Close() // Cleans up all providers
```
### 4. Testability
**Before**: Hard to test, coupled to filesystem
**After**: Mock providers for testing
```go
mockProvider := testing.NewInMemoryProvider(map[string][]byte{
"index.html": []byte("<html>test</html>"),
})
service.Mount(MountConfig{
URLPrefix: "/",
Provider: mockProvider,
})
```
### 5. Extensibility
**Before**: Need to modify code to support new file sources
**After**: Implement `FileSystemProvider` interface
```go
// Future: Add HTTP provider without changing core code
type HTTPFSProvider struct { /* ... */ }
func (h *HTTPFSProvider) Open(name string) (fs.File, error) { /* ... */ }
func (h *HTTPFSProvider) Close() error { /* ... */ }
func (h *HTTPFSProvider) Type() string { return "http" }
```
## Future Features (To Implement Later)
### Remote HTTP/HTTPS Provider
**File**: `providers/http.go` (FUTURE)
Serve static files from remote HTTP servers with local caching:
```go
type HTTPFSProvider struct {
baseURL string
httpClient *http.Client
cache LocalCache // Optional disk/memory cache
cacheTTL time.Duration
mu sync.RWMutex
}
// Example usage
service.Mount(MountConfig{
URLPrefix: "/cdn",
Provider: HTTPProvider("https://cdn.example.com/assets"),
})
```
**Features**:
- Fetch files from remote URLs on-demand
- Local cache to reduce remote requests
- Configurable TTL and cache eviction
- HEAD request support for metadata
- Retry logic and timeout handling
- Support for authentication headers
### S3-Compatible Provider
**File**: `providers/s3.go` (FUTURE)
Serve static files from S3, MinIO, or S3-compatible storage:
```go
type S3FSProvider struct {
bucket string
prefix string
region string
client *s3.Client
cache LocalCache
mu sync.RWMutex
}
// Example usage
service.Mount(MountConfig{
URLPrefix: "/media",
Provider: S3Provider("my-bucket", "static/", "us-east-1"),
})
```
**Features**:
- List and fetch objects from S3 buckets
- Support for AWS S3, MinIO, DigitalOcean Spaces, etc.
- IAM role or credential-based authentication
- Optional local caching layer
- Efficient metadata retrieval
- Support for presigned URLs
### Other Future Providers
- **GitProvider**: Serve files from Git repositories
- **MemoryProvider**: In-memory filesystem for testing/temporary files
- **ProxyProvider**: Proxy to another static file server
- **CompositeProvider**: Fallback chain across multiple providers
### Advanced Cache Policies (FUTURE)
- **TimedCachePolicy**: Different cache times by time of day
- **UserAgentCachePolicy**: Cache based on client type
- **ConditionalCachePolicy**: Smart cache based on file size/type
- **DistributedCachePolicy**: Shared cache across service instances
### Advanced Fallback Strategies (FUTURE)
- **RegexFallbackStrategy**: Pattern-based routing
- **I18nFallbackStrategy**: Language-based file resolution
- **VersionedFallbackStrategy**: A/B testing support
## Critical Files Summary
### Files to CREATE (in order):
1. `pkg/server/staticweb/interfaces.go` - Core contracts
2. `pkg/server/staticweb/config.go` - Configuration structs and helpers
3. `pkg/server/staticweb/policies/cache.go` - Cache policy implementations
4. `pkg/server/staticweb/policies/mime.go` - MIME resolver implementations
5. `pkg/server/staticweb/policies/fallback.go` - Fallback strategy implementations
6. `pkg/server/staticweb/providers/local.go` - Local directory provider
7. `pkg/server/staticweb/providers/zip.go` - Zip file provider
8. `pkg/server/staticweb/providers/embed.go` - Embedded filesystem provider
9. `pkg/server/staticweb/mount.go` - Mount point implementation
10. `pkg/server/staticweb/service.go` - Main service implementation
11. `pkg/server/staticweb/testing/mocks.go` - Test helpers
12. `pkg/server/staticweb/service_test.go` - Service tests
13. `pkg/server/staticweb/providers/providers_test.go` - Provider tests
14. `pkg/server/staticweb/example_test.go` - Example code
15. `pkg/server/staticweb/README.md` - Documentation
### Files to REMOVE:
1. `pkg/server/staticweb/staticweb.go` - Replaced by new design
### Files to KEEP:
1. `pkg/server/zipfs/zipfs.go` - Used by ZipFSProvider
### Files for FUTURE (not in this refactoring):
1. `pkg/server/staticweb/providers/http.go` - HTTP/HTTPS remote provider
2. `pkg/server/staticweb/providers/s3.go` - S3-compatible storage provider
3. `pkg/server/staticweb/providers/composite.go` - Fallback chain provider
## Example Usage After Refactoring
### Basic Static Site
```go
service := staticweb.NewService(nil) // Use defaults
err := service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.LocalProvider("./public"),
})
muxRouter.PathPrefix("/").Handler(service.Handler())
```
### SPA with API Routes
```go
service := staticweb.NewService(nil)
service.Mount(staticweb.MountConfig{
URLPrefix: "/",
Provider: staticweb.LocalProvider("./dist"),
FallbackStrategy: staticweb.HTMLFallback("index.html"),
})
// API routes take precedence (registered first)
muxRouter.HandleFunc("/api/users", usersHandler)
muxRouter.HandleFunc("/api/posts", postsHandler)
// Static files handle all other routes
muxRouter.PathPrefix("/").Handler(service.Handler())
```
### Multiple Mount Points with Different Policies
```go
service := staticweb.NewService(&staticweb.ServiceConfig{
DefaultCacheTime: 3600,
})
// Assets with long cache
service.Mount(staticweb.MountConfig{
URLPrefix: "/assets",
Provider: staticweb.LocalProvider("./assets"),
CachePolicy: staticweb.SimpleCache(604800), // 1 week
})
// HTML with short cache
service.Mount(staticweb.MountConfig{
URLPrefix: "/",
Provider: staticweb.LocalProvider("./public"),
CachePolicy: staticweb.SimpleCache(300), // 5 minutes
})
router.PathPrefix("/").Handler(service.Handler())
```
### Embedded Files from Zip
```go
//go:embed assets.zip
var assetsZip embed.FS
service := staticweb.NewService(nil)
service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.EmbedProvider(&assetsZip, "assets.zip"),
})
router.PathPrefix("/").Handler(service.Handler())
```
### Future: CDN Fallback (when HTTP provider is implemented)
```go
// Primary CDN with local fallback
service.Mount(staticweb.MountConfig{
URLPrefix: "/static",
Provider: staticweb.CompositeProvider(
staticweb.HTTPProvider("https://cdn.example.com/assets"),
staticweb.LocalProvider("./public/assets"),
),
})
```
## Testing Strategy
### Unit Tests
- Each provider implementation independently
- Each policy implementation independently
- Mount point request handling
- Service mount/unmount operations
### Integration Tests
- Full request flow through service
- Multiple mount points
- Longest-prefix matching
- Resource cleanup
### Example Tests
- Executable examples in `example_test.go`
- Demonstrate common usage patterns
## Migration Impact
### Breaking Changes
- Complete API redesign (acceptable per user)
- Package not currently used in codebase (no migration needed)
- New consumers will use new API from start
### Future Extensibility
The interface-driven design allows future additions without breaking changes:
- Add HTTPFSProvider by implementing `FileSystemProvider`
- Add S3FSProvider by implementing `FileSystemProvider`
- Add custom cache policies by implementing `CachePolicy`
- Add custom fallback strategies by implementing `FallbackStrategy`
## Implementation Order
1. Interfaces (foundation)
2. Configuration (API surface)
3. Policies (pluggable behavior)
4. Providers (filesystem abstraction)
5. Mount Point (request handling)
6. Service (orchestration)
7. Tests (validation)
8. Documentation (usage)
9. Remove old code (cleanup)
This order ensures each layer builds on tested, working components.
---