Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
9 changes: 4 additions & 5 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,11 @@ jobs:
- name: Run go vet
run: go vet ./...

- name: Install golangci-lint
run: |
curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.55.2

- name: Run golangci-lint
run: golangci-lint run --timeout=5m
uses: golangci/golangci-lint-action@v8
with:
version: v2.12.2
args: --timeout=5m

build:
name: Build Examples
Expand Down
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,12 @@ examples/*/jwt-service
examples/*/httpclient-service
examples/*/prometheus-service
examples/*/config-service

# Compiled example binaries (go build in examples/<name>/ produces examples/<name>/<name>)
examples/simple-service/simple-service
examples/config-service/config-service
examples/jwt-service/jwt-service
examples/postgresql-service/postgresql-service
examples/prometheus-service/prometheus-service
examples/redis-service/redis-service
examples/httpclient-service/httpclient-service
39 changes: 23 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ Forge is inspired by DropWizard but designed specifically for Go's strengths - i
- **Observability Built-in**: OpenTelemetry tracing, structured logging, Prometheus metrics
- **Health Checks**: Comprehensive liveness and readiness checks
- **Graceful Lifecycle**: Sophisticated startup and shutdown orchestration
- **Configuration Management**: Environment-based config with validation
- **Database Integration**: Transaction-safe PostgreSQL patterns
- **Event Publishing**: Redis Streams integration
- **Configuration Management**: Validated config with YAML + env overrides via the configloader bundle
- **Database Integration**: PostgreSQL connection pooling with health checks
- **Redis Integration**: Caching, pub/sub, distributed locks, and rate limiting
- **Resilient HTTP Client**: Circuit breaker, retries, and backoff built in
- **Security First**: No hardcoded credentials, explicit validation requirements

## Quick Start
Expand All @@ -24,21 +25,29 @@ package main

import (
"context"
"github.com/datariot/forge/framework"
"log"

"github.com/datariot/forge/bundles/postgresql"
"github.com/datariot/forge/config"
"github.com/datariot/forge/framework"
)

func main() {
cfg := MustLoadConfig()
cfg := config.DefaultBaseConfig()
cfg.ServiceName = "my-service"

myComponent := NewMyComponent(cfg)
pgConfig := postgresql.DefaultConfig()
pgConfig.DatabaseURL = "postgres://user:pass@localhost:5432/mydb"

app := framework.New(
framework.WithConfig(&cfg.BaseConfig),
app, err := framework.New(
framework.WithConfig(&cfg),
framework.WithVersion("1.0.0"),
framework.WithComponent(myComponent),
framework.WithBundle(postgresql.Bundle()),
framework.WithComponent(NewMyComponent(&cfg)),
framework.WithBundle(postgresql.NewBundle(pgConfig)),
)
if err != nil {
log.Fatal(err)
}

if err := app.Run(context.Background()); err != nil {
log.Fatal(err)
Expand Down Expand Up @@ -112,17 +121,15 @@ See [TESTING.md](TESTING.md) for comprehensive testing strategy.
Forge follows Clean Architecture principles:

- **Framework**: Core application lifecycle and interfaces
- **Bundles**: Pre-built integrations (PostgreSQL, Redis, Prometheus, etc.)
- **Bundles**: Pre-built integrations (PostgreSQL, Redis, JWT, HTTP client, Prometheus, configloader)
- **Components**: Your business logic implementing framework interfaces
- **Adapters**: Infrastructure integrations
- **Config**: Environment-driven configuration management
- **Config**: Common service configuration with validation

## Documentation

- [Getting Started](docs/getting-started.md)
- [Component Development](docs/components.md)
- [Configuration Guide](docs/configuration.md)
- [Health Checks](docs/health-checks.md)
- [API Reference](docs/api-reference.md)
- [Bundles Guide](docs/bundles.md)
- [Examples](examples/)
- [Development Guide](CLAUDE.md)

Expand Down
57 changes: 31 additions & 26 deletions bundles/configloader/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,13 +51,16 @@
// # Environment Variable Binding
//
// The loader automatically binds environment variables based on:
//
// - Field names (converted to UPPER_SNAKE_CASE)
//
// - `env` struct tags for custom names
//
// - `envPrefix` configuration for namespacing
//
// DatabaseURL string `env:"DATABASE_URL"` // Exact name
// APITimeout int `env:"API_TIMEOUT_SECONDS"` // Custom name
// Debug bool // Automatic: DEBUG or MYSERVICE_DEBUG (with prefix)
// DatabaseURL string `env:"DATABASE_URL"` // Exact name
// APITimeout int `env:"API_TIMEOUT_SECONDS"` // Custom name
// Debug bool // Automatic: DEBUG or MYSERVICE_DEBUG (with prefix)
//
// # Hot Reload
//
Expand All @@ -80,6 +83,7 @@ package configloader
import (
"context"
"encoding/json"
stderrors "errors"
"fmt"
"io"
"os"
Expand Down Expand Up @@ -122,8 +126,8 @@ type Config struct {
SecureLogging bool

// Security configuration
MaxFileSize int64 // Maximum configuration file size (default: 1MB)
AllowedPaths []string // Allowed configuration file directories
MaxFileSize int64 // Maximum configuration file size (default: 1MB)
AllowedPaths []string // Allowed configuration file directories
RequiredFileMode os.FileMode // Required file permissions (default: 0o644)
}

Expand All @@ -149,13 +153,13 @@ func DefaultConfig() Config {
// Validate validates the configuration loader settings.
func (c *Config) Validate() error {
if len(c.ConfigPaths) == 0 {
return fmt.Errorf("at least one config path must be specified")
return stderrors.New("at least one config path must be specified")
}

// Validate config paths
for _, path := range c.ConfigPaths {
if path == "" {
return fmt.Errorf("config path cannot be empty")
return stderrors.New("config path cannot be empty")
}
if !filepath.IsAbs(path) && !strings.HasPrefix(path, "./") {
return fmt.Errorf("config path must be absolute or relative (starting with ./): %s", path)
Expand All @@ -172,7 +176,6 @@ type Bundle struct {
watcher *fsnotify.Watcher
watchedFiles []string
logger zerolog.Logger
mu sync.RWMutex
}

// NewBundle creates a new configuration loading bundle.
Expand Down Expand Up @@ -238,7 +241,9 @@ func (b *Bundle) Stop(ctx context.Context) error {
return err
case <-ctx.Done():
// Force close after timeout
b.watcher.Close()
if closeErr := b.watcher.Close(); closeErr != nil {
b.logger.Error().Err(closeErr).Msg("failed to force-close config watcher")
}
return fmt.Errorf("config watcher close timed out: %w", ctx.Err())
}
}
Expand Down Expand Up @@ -275,30 +280,30 @@ func (b *Bundle) initializeWatcher() error {

// Loader provides configuration loading functionality.
type Loader struct {
config Config
loadedFrom string
config Config
loadedFrom string
changeCallbacks []func(interface{})
mu sync.RWMutex
mu sync.RWMutex
}

// LoadResult contains information about the configuration loading process.
type LoadResult struct {
LoadedFrom string `json:"loaded_from"`
Sources []string `json:"sources"`
EnvVarsUsed []string `json:"env_vars_used"`
DefaultsApplied []string `json:"defaults_applied"`
ValidationErrors []string `json:"validation_errors,omitempty"`
LoadedFrom string `json:"loaded_from"`
Sources []string `json:"sources"`
EnvVarsUsed []string `json:"env_vars_used"`
DefaultsApplied []string `json:"defaults_applied"`
ValidationErrors []string `json:"validation_errors,omitempty"`
}

// Load loads configuration into the provided struct from multiple sources.
func (l *Loader) Load(dest interface{}) (*LoadResult, error) {
if dest == nil {
return nil, fmt.Errorf("destination cannot be nil")
return nil, stderrors.New("destination cannot be nil")
}

destValue := reflect.ValueOf(dest)
if destValue.Kind() != reflect.Ptr || destValue.Elem().Kind() != reflect.Struct {
return nil, fmt.Errorf("destination must be a pointer to a struct")
if destValue.Kind() != reflect.Pointer || destValue.Elem().Kind() != reflect.Struct {
return nil, stderrors.New("destination must be a pointer to a struct")
}

result := &LoadResult{
Expand Down Expand Up @@ -391,7 +396,7 @@ func (l *Loader) loadFromFile(filename string, dest interface{}) error {
if err != nil {
return fmt.Errorf("failed to open config file: %w", err)
}
defer file.Close()
defer func() { _ = file.Close() }()

// Use io.LimitReader to enforce size limits
limitedReader := io.LimitReader(file, l.config.MaxFileSize)
Expand Down Expand Up @@ -567,7 +572,7 @@ func (l *Loader) setFieldValue(field reflect.Value, value string) error {
field.Set(reflect.ValueOf(values))
}
} else {
return fmt.Errorf("unsupported slice type for field")
return stderrors.New("unsupported slice type for field")
}
default:
return fmt.Errorf("unsupported field type: %s", field.Kind())
Expand Down Expand Up @@ -685,7 +690,7 @@ func (l *Loader) OnConfigChange(callback func(interface{})) {
// StartWatching starts watching configuration files for changes.
func (b *Bundle) StartWatching(ctx context.Context, dest interface{}) error {
if b.watcher == nil {
return fmt.Errorf("file watcher not initialized")
return stderrors.New("file watcher not initialized")
}

go func() {
Expand Down Expand Up @@ -777,14 +782,14 @@ func (l *Loader) Reload(dest interface{}) (*LoadResult, error) {
// Paths containing ".." traversal sequences are rejected regardless of resolution outcome.
func (l *Loader) validateFilePath(filename string) error {
if filename == "" {
return fmt.Errorf("configuration file path cannot be empty")
return stderrors.New("configuration file path cannot be empty")
}

// Reject inputs that contain path traversal sequences before any resolution.
// filepath.Clean / filepath.Abs would silently resolve "../../../etc/passwd" to
// a valid absolute path, making it look legitimate. We block such inputs explicitly.
if strings.Contains(filepath.Clean(filename), "..") {
return fmt.Errorf("path traversal not allowed in configuration file path")
return stderrors.New("path traversal not allowed in configuration file path")
}

// Resolve relative paths to absolute
Expand Down Expand Up @@ -840,4 +845,4 @@ func MustLoadConfig[T any](configPaths ...string) (*T, *LoadResult) {
panic(fmt.Sprintf("Configuration loading failed: %v", err))
}
return cfg, result
}
}
Loading
Loading