feat: implement Epic 0 - Project Setup & Foundation

Implemented all 5 stories from Epic 0:

Story 0.1: Project Initialization
- Initialize Go module with path git.dcentral.systems/toolz/goplt
- Create complete directory structure (cmd/, internal/, pkg/, modules/, config/, etc.)
- Add comprehensive .gitignore for Go projects
- Create README.md with project overview and setup instructions

Story 0.2: Configuration Management System
- Define ConfigProvider interface in pkg/config
- Implement Viper-based configuration in internal/config
- Create configuration loader with environment support
- Add default, development, and production YAML config files

Story 0.3: Structured Logging System
- Define Logger interface in pkg/logger
- Implement Zap-based logger in internal/logger
- Add request ID middleware for Gin
- Create global logger export with convenience functions
- Support context-aware logging with request/user ID extraction

Story 0.4: CI/CD Pipeline
- Create GitHub Actions workflow for CI (test, lint, build, fmt)
- Add comprehensive Makefile with development commands
- Configure golangci-lint with reasonable defaults

Story 0.5: Dependency Injection and Bootstrap
- Create FX-based DI container in internal/di
- Implement provider functions for Config and Logger
- Create application entry point in cmd/platform/main.go
- Add lifecycle management with graceful shutdown

All acceptance criteria met:
- go build ./cmd/platform succeeds
- go test ./... runs successfully
- go mod verify passes
- Config loads from config/default.yaml
- Logger can be injected and used
- Application starts and shuts down gracefully
This commit is contained in:
2025-11-05 12:21:15 +01:00
parent 3f90262860
commit 4724a2efb5
21 changed files with 1442 additions and 17 deletions

102
internal/config/config.go Normal file
View File

@@ -0,0 +1,102 @@
package config
import (
"fmt"
"time"
"github.com/spf13/viper"
"git.dcentral.systems/toolz/goplt/pkg/config"
)
// viperConfig implements the ConfigProvider interface using Viper.
type viperConfig struct {
v *viper.Viper
}
// NewViperConfig creates a new Viper-based configuration provider.
func NewViperConfig(v *viper.Viper) config.ConfigProvider {
return &viperConfig{v: v}
}
// Get retrieves a configuration value by key.
func (vc *viperConfig) Get(key string) any {
return vc.v.Get(key)
}
// Unmarshal unmarshals the entire configuration into the provided struct.
func (vc *viperConfig) Unmarshal(v any) error {
return vc.v.Unmarshal(v)
}
// GetString retrieves a string value by key.
func (vc *viperConfig) GetString(key string) string {
return vc.v.GetString(key)
}
// GetInt retrieves an integer value by key.
func (vc *viperConfig) GetInt(key string) int {
return vc.v.GetInt(key)
}
// GetBool retrieves a boolean value by key.
func (vc *viperConfig) GetBool(key string) bool {
return vc.v.GetBool(key)
}
// GetStringSlice retrieves a string slice value by key.
func (vc *viperConfig) GetStringSlice(key string) []string {
return vc.v.GetStringSlice(key)
}
// GetDuration retrieves a duration value by key.
func (vc *viperConfig) GetDuration(key string) time.Duration {
return vc.v.GetDuration(key)
}
// IsSet checks if a configuration key is set.
func (vc *viperConfig) IsSet(key string) bool {
return vc.v.IsSet(key)
}
// LoadConfig loads configuration from files and environment variables.
// It follows this precedence order (highest to lowest):
// 1. Environment variables
// 2. Environment-specific YAML files (development.yaml, production.yaml)
// 3. Default YAML file (default.yaml)
//
// The env parameter determines which environment-specific file to load.
// Supported values: "development", "production", or empty string for default only.
func LoadConfig(env string) (config.ConfigProvider, error) {
v := viper.New()
// Set default configuration file name
v.SetConfigName("default")
v.SetConfigType("yaml")
v.AddConfigPath("config")
// Read default configuration
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read default config: %w", err)
}
// Load environment-specific configuration if specified
if env != "" {
v.SetConfigName(env)
// Merge environment-specific config (if it exists)
if err := v.MergeInConfig(); err != nil {
// Environment-specific file is optional, so we only warn
// but don't fail if it doesn't exist
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to merge environment config: %w", err)
}
}
}
// Enable environment variable support
v.AutomaticEnv()
// Environment variables can be set in UPPER_SNAKE_CASE format
// and will automatically map to nested keys (e.g., SERVER_PORT -> server.port)
// Viper handles this automatically with AutomaticEnv()
return NewViperConfig(v), nil
}

66
internal/di/container.go Normal file
View File

@@ -0,0 +1,66 @@
package di
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"go.uber.org/fx"
)
// Container wraps the FX application and provides lifecycle management.
type Container struct {
app *fx.App
}
// NewContainer creates a new DI container with the provided options.
func NewContainer(opts ...fx.Option) *Container {
// Add core module
allOpts := []fx.Option{CoreModule()}
allOpts = append(allOpts, opts...)
app := fx.New(allOpts...)
return &Container{
app: app,
}
}
// Start starts the container and blocks until shutdown.
func (c *Container) Start(ctx context.Context) error {
// Start the FX app
if err := c.app.Start(ctx); err != nil {
return err
}
// Wait for interrupt signal
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Block until signal received
<-sigChan
// Create shutdown context
shutdownCtx, cancel := context.WithTimeout(context.Background(), c.getShutdownTimeout())
defer cancel()
// Stop the FX app
if err := c.app.Stop(shutdownCtx); err != nil {
return err
}
return nil
}
// Stop stops the container gracefully.
func (c *Container) Stop(ctx context.Context) error {
return c.app.Stop(ctx)
}
// getShutdownTimeout returns the shutdown timeout duration.
// Can be made configurable in the future.
func (c *Container) getShutdownTimeout() time.Duration {
return 30 * time.Second
}

83
internal/di/providers.go Normal file
View File

@@ -0,0 +1,83 @@
package di
import (
"context"
"fmt"
"os"
"go.uber.org/fx"
configimpl "git.dcentral.systems/toolz/goplt/internal/config"
loggerimpl "git.dcentral.systems/toolz/goplt/internal/logger"
"git.dcentral.systems/toolz/goplt/pkg/config"
"git.dcentral.systems/toolz/goplt/pkg/logger"
)
// ProvideConfig creates an FX option that provides ConfigProvider.
func ProvideConfig() fx.Option {
return fx.Provide(func() (config.ConfigProvider, error) {
// Determine environment from environment variable or default to "development"
env := os.Getenv("ENVIRONMENT")
if env == "" {
env = "development"
}
cfg, err := configimpl.LoadConfig(env)
if err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
return cfg, nil
})
}
// ProvideLogger creates an FX option that provides Logger.
func ProvideLogger() fx.Option {
return fx.Provide(func(cfg config.ConfigProvider) (logger.Logger, error) {
level := cfg.GetString("logging.level")
if level == "" {
level = "info"
}
format := cfg.GetString("logging.format")
if format == "" {
format = "json"
}
log, err := loggerimpl.NewZapLogger(level, format)
if err != nil {
return nil, fmt.Errorf("failed to create logger: %w", err)
}
// Set as global logger
logger.SetGlobalLogger(log)
return log, nil
})
}
// CoreModule returns an FX option that provides all core services.
// This includes configuration and logging.
func CoreModule() fx.Option {
return fx.Options(
ProvideConfig(),
ProvideLogger(),
)
}
// RegisterLifecycleHooks registers lifecycle hooks for logging.
func RegisterLifecycleHooks(lc fx.Lifecycle, l logger.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
l.Info("Application starting",
logger.String("component", "bootstrap"),
)
return nil
},
OnStop: func(ctx context.Context) error {
l.Info("Application shutting down",
logger.String("component", "bootstrap"),
)
return nil
},
})
}

View File

@@ -0,0 +1,92 @@
package logger
import (
"context"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"git.dcentral.systems/toolz/goplt/pkg/logger"
)
const (
// RequestIDHeader is the HTTP header name for request ID.
RequestIDHeader = "X-Request-ID"
)
// RequestIDMiddleware creates a Gin middleware that:
// 1. Generates a unique request ID for each request (or uses existing one from header)
// 2. Adds the request ID to the request context
// 3. Adds the request ID to the response headers
// 4. Makes the request ID available for logging
func RequestIDMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Check if request ID already exists in header
requestID := c.GetHeader(RequestIDHeader)
// Generate new request ID if not present
if requestID == "" {
requestID = uuid.New().String()
}
// Add request ID to context
ctx := context.WithValue(c.Request.Context(), RequestIDKey(), requestID)
c.Request = c.Request.WithContext(ctx)
// Add request ID to response header
c.Header(RequestIDHeader, requestID)
// Continue processing
c.Next()
}
}
// RequestIDFromContext extracts the request ID from the context.
func RequestIDFromContext(ctx context.Context) string {
if requestID, ok := ctx.Value(RequestIDKey()).(string); ok {
return requestID
}
return ""
}
// SetRequestID sets the request ID in the context.
func SetRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, RequestIDKey(), requestID)
}
// SetUserID sets the user ID in the context.
func SetUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, UserIDKey(), userID)
}
// UserIDFromContext extracts the user ID from the context.
func UserIDFromContext(ctx context.Context) string {
if userID, ok := ctx.Value(UserIDKey()).(string); ok {
return userID
}
return ""
}
// LoggingMiddleware creates a Gin middleware that logs HTTP requests.
// It uses the logger from the context and includes request ID.
func LoggingMiddleware(l logger.Logger) gin.HandlerFunc {
return func(c *gin.Context) {
// Get logger with context
log := l.WithContext(c.Request.Context())
// Log request
log.Info("HTTP request",
logger.String("method", c.Request.Method),
logger.String("path", c.Request.URL.Path),
logger.String("remote_addr", c.ClientIP()),
)
// Process request
c.Next()
// Log response
log.Info("HTTP response",
logger.Int("status", c.Writer.Status()),
logger.Int("size", c.Writer.Size()),
)
}
}

View File

@@ -0,0 +1,133 @@
package logger
import (
"context"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"git.dcentral.systems/toolz/goplt/pkg/logger"
)
const (
// Context keys for extracting values from context
requestIDKey = "request_id"
userIDKey = "user_id"
)
// zapLogger implements the Logger interface using zap.
type zapLogger struct {
zap *zap.Logger
}
// NewZapLogger creates a new zap-based logger.
// The format parameter determines the output format:
// - "json": JSON format (production)
// - "console": Human-readable format (development)
func NewZapLogger(level string, format string) (logger.Logger, error) {
var zapConfig zap.Config
var zapLevel zapcore.Level
// Parse log level
if err := zapLevel.UnmarshalText([]byte(level)); err != nil {
zapLevel = zapcore.InfoLevel
}
// Configure based on format
if format == "json" {
zapConfig = zap.NewProductionConfig()
} else {
zapConfig = zap.NewDevelopmentConfig()
}
zapConfig.Level = zap.NewAtomicLevelAt(zapLevel)
zapConfig.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
z, err := zapConfig.Build()
if err != nil {
return nil, err
}
return &zapLogger{zap: z}, nil
}
// Debug logs a message at debug level.
func (zl *zapLogger) Debug(msg string, fields ...logger.Field) {
zl.zap.Debug(msg, convertFields(fields)...)
}
// Info logs a message at info level.
func (zl *zapLogger) Info(msg string, fields ...logger.Field) {
zl.zap.Info(msg, convertFields(fields)...)
}
// Warn logs a message at warning level.
func (zl *zapLogger) Warn(msg string, fields ...logger.Field) {
zl.zap.Warn(msg, convertFields(fields)...)
}
// Error logs a message at error level.
func (zl *zapLogger) Error(msg string, fields ...logger.Field) {
zl.zap.Error(msg, convertFields(fields)...)
}
// With creates a child logger with the specified fields.
func (zl *zapLogger) With(fields ...logger.Field) logger.Logger {
return &zapLogger{
zap: zl.zap.With(convertFields(fields)...),
}
}
// WithContext creates a child logger with fields extracted from context.
func (zl *zapLogger) WithContext(ctx context.Context) logger.Logger {
fields := make([]logger.Field, 0)
// Extract request ID from context
if requestID, ok := ctx.Value(requestIDKey).(string); ok && requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
// Extract user ID from context
if userID, ok := ctx.Value(userIDKey).(string); ok && userID != "" {
fields = append(fields, zap.String("user_id", userID))
}
if len(fields) == 0 {
return zl
}
return &zapLogger{
zap: zl.zap.With(convertFields(fields)...),
}
}
// convertFields converts logger.Field to zap.Field.
// Since Field is an alias for zap.Field, we can cast directly.
func convertFields(fields []logger.Field) []zap.Field {
if len(fields) == 0 {
return nil
}
zapFields := make([]zap.Field, 0, len(fields))
for _, f := range fields {
// Type assert to zap.Field
if zf, ok := f.(zap.Field); ok {
zapFields = append(zapFields, zf)
} else {
// Fallback: convert to Any field
zapFields = append(zapFields, zap.Any("field", f))
}
}
return zapFields
}
// RequestIDKey returns the context key for request ID.
// This is exported so modules can use it to set request IDs in context.
func RequestIDKey() string {
return requestIDKey
}
// UserIDKey returns the context key for user ID.
// This is exported so modules can use it to set user IDs in context.
func UserIDKey() string {
return userIDKey
}