diff --git a/go.mod b/go.mod index cd763c5..a850a4e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,11 @@ module gitea.auvem.com/go-toolkit/app go 1.24.0 + +require github.com/stretchr/testify v1.10.0 + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..713a0b4 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/lifecycle.go b/lifecycle.go index e609ced..6453dba 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -1,6 +1,7 @@ package app import ( + "context" "errors" "fmt" "log/slog" @@ -8,19 +9,23 @@ import ( "strings" ) +type contextKey string + +const lifecycleContextKey contextKey = "lifecycle" + // LifecycleOpts contains user-exposed options when defining a lifecycle. type LifecycleOpts struct { // DisableAutoload disables dependency autoloading (enabled by default). DisableAutoload bool + + // Logger is the logger for the lifecycle. If not set, it will be initialized + // by the lifecycle using the default logger (slog.Default). + Logger *slog.Logger } // Lifecycle represents the core application structure. Lifecycle manages // resources providing an orchestrator for setup and teardown of modules. type Lifecycle struct { - // Logger is the logger for the lifecycle. If not set, it will be initialized - // by the lifecycle itself using the default logger. - logger *slog.Logger - modules []*Module opts LifecycleOpts @@ -33,46 +38,59 @@ type Lifecycle struct { // NewLifecycle creates a new Lifecycle instance with a default logger and the // given modules. It panics if any module has a duplicate name. func NewLifecycle(modules ...*Module) *Lifecycle { - return NewLifecycleL(nil, modules...) -} - -// NewLifecycleL creates a new Lifecycle instance with a specific logger and the -// given modules. See NewLifecycle for more details. -func NewLifecycleL(defaultLogger *slog.Logger, modules ...*Module) *Lifecycle { - // Use the provided logger or initialize a default one - if defaultLogger == nil { - defaultLogger = slog.Default() - } - // Ensure modules are unique unique := make(map[string]bool) for _, mod := range modules { if _, exists := unique[mod.name]; exists { - panic(fmt.Sprintf("duplicate module name: %s", mod.name)) + panic(fmt.Sprintf("duplicate module: %s", mod)) } unique[mod.name] = true } return &Lifecycle{ - logger: defaultLogger, modules: modules, setupTracker: make(map[string]int), teardownTracker: make(map[string]int), } } +// LifecycleToContext adds the Lifecycle to the context. +func LifecycleToContext(ctx context.Context, lifecycle *Lifecycle) context.Context { + if lifecycle == nil { + return ctx + } + return context.WithValue(ctx, lifecycleContextKey, lifecycle) +} + +// LifecycleFromContext retrieves the Lifecycle from the context. Returns nil if not found. +func LifecycleFromContext(ctx context.Context) *Lifecycle { + if lifecycle, ok := ctx.Value(lifecycleContextKey).(*Lifecycle); ok { + return lifecycle + } + return nil +} + // WithOpts sets the options for the lifecycle. func (app *Lifecycle) WithOpts(opts LifecycleOpts) *Lifecycle { app.opts = opts return app } +// WithLogger sets the logger for the lifecycle. Panics if the logger is nil. +func (app *Lifecycle) WithLogger(logger *slog.Logger) *Lifecycle { + if logger == nil { + panic("logger cannot be nil") + } + app.opts.Logger = logger + return app +} + // Logger returns the logger for the lifecycle. func (app *Lifecycle) Logger() *slog.Logger { - if app.logger == nil { - panic("lifecycle logger is not initialized") + if app.opts.Logger == nil { + app.opts.Logger = slog.Default() } - return app.logger + return app.opts.Logger } // Setup initializes all modules in the order they were defined and checks @@ -81,12 +99,12 @@ func (app *Lifecycle) Logger() *slog.Logger { // of the application lifecycle to ensure all resources are cleaned up properly. func (app *Lifecycle) Setup() error { for _, mod := range app.modules { - if err := app.setupSingle(mod, app.logger); err != nil { + if err := app.setupSingle(nil, mod); err != nil { return err } } - app.logger.Info("Lifecycle modules initialized, backend setup complete", + app.Logger().Info("Lifecycle modules initialized, backend setup complete", "modules", mapToString(app.setupTracker)) return nil @@ -105,11 +123,11 @@ func (app *Lifecycle) Teardown() error { } if err != nil { - app.logger.Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err) + app.Logger().Error("Error tearing down modules", "failures", app.setupCount-app.teardownCount, "error", err) return err } - app.logger.Info("All modules torn down, backend teardown complete", + app.Logger().Info("All modules torn down, backend teardown complete", "modules", mapToString(app.teardownTracker)) return nil @@ -120,13 +138,13 @@ func (app *Lifecycle) Teardown() error { // but may still be conditionally necessary. Any modules that are already // set up are ignored. func (app *Lifecycle) Require(modules ...*Module) error { - return app.RequireL(app.logger, modules...) + return app.require(nil, false, modules...) } // RequireUnique is the same as Require, but it returns an error if any requested // module is already set up--rather than ignoring it. func (app *Lifecycle) RequireUnique(modules ...*Module) error { - return app.RequireUniqueL(app.logger, modules...) + return app.require(nil, true, modules...) } // RequireL adds module(s) to the lifecycle with a specific logger and @@ -135,21 +153,39 @@ func (app *Lifecycle) RequireUnique(modules ...*Module) error { // default logger. func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error { if logger == nil { - logger = app.logger + return fmt.Errorf("logger cannot be nil") } + return app.require(logger, false, modules...) +} +// RequireUniqueL is the same as RequireL, but it returns an error if any requested +// module is already set up--rather than ignoring it. +func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error { + if logger == nil { + return fmt.Errorf("logger cannot be nil") + } + return app.require(logger, true, modules...) +} + +// require is a helper function that attempts to add module(s) to the lifecycle +// and immediately run any setup functions. +func (app *Lifecycle) require(logger *slog.Logger, unique bool, modules ...*Module) error { if len(modules) == 0 { return fmt.Errorf("no modules to require") } - for _, mod := range modules { + for i, mod := range modules { if mod == nil { - return fmt.Errorf("module is nil") + return fmt.Errorf("module %d is nil", i) } - // Check if the module is already set up + // Check if the module has already been set up if _, ok := app.setupTracker[mod.name]; ok { - app.logger.Warn("module already set up, ignoring", "module", mod.name) + if unique { + return fmt.Errorf("module %s is already set up, cannot require it again", mod) + } + + app.Logger().Warn("module already set up, ignoring", "module", mod) mod.loaded = true mod.logger = logger continue @@ -159,57 +195,40 @@ func (app *Lifecycle) RequireL(logger *slog.Logger, modules ...*Module) error { app.modules = append(app.modules, mod) // Run the setup function for the module - if err := app.setupSingle(mod, logger); err != nil { - return fmt.Errorf("error setting up required module '%s': %w", mod.name, err) + if err := app.setupSingle(logger, mod); err != nil { + return fmt.Errorf("error setting up required module %s: %w", mod, err) } } - app.logger.Info("New modules initialized", "all", mapToString(app.setupTracker)) - + app.Logger().Info("New modules initialized", "all", mapToString(app.setupTracker)) return nil } -// RequireUniqueL is the same as RequireL, but it returns an error if any requested -// module is already set up--rather than ignoring it. -func (app *Lifecycle) RequireUniqueL(logger *slog.Logger, modules ...*Module) error { - // Check if any module is already set up - for _, mod := range modules { - if mod == nil { - return fmt.Errorf("module is nil") - } - - if _, ok := app.setupTracker[mod.name]; ok { - return fmt.Errorf("module '%s' is already set up, cannot require it again", mod.name) - } - } - - return app.RequireL(logger, modules...) -} - // setupSingle is a helper function to set up a single module. Returns an error // if the module cannot be set up or if dependencies are not satisfied. -func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) error { +func (app *Lifecycle) setupSingle(logger *slog.Logger, mod *Module) error { if mod == nil { return fmt.Errorf("module is nil") } - // Set the logger for the module + // Set the parent lifecycle and logger override + mod.lifecycle = app mod.logger = logger // Check if all dependencies are satisfied for _, dep := range mod.depends { if _, ok := app.setupTracker[dep]; !ok { if app.opts.DisableAutoload { - return fmt.Errorf("dependency '%s' not satisfied for '%s'", dep, mod.name) + return fmt.Errorf("dependency %s not satisfied for '%s'", dep, mod) } else { // Attempt to set up the dependency depmod, err := app.getModuleByName(dep) if err != nil { - return fmt.Errorf("error getting dependency '%s' for '%s': %w", dep, mod.name, err) + return fmt.Errorf("error getting dependency '%s' for %s: %w", dep, mod, err) } - if err := app.setupSingle(depmod, logger); err != nil { - return fmt.Errorf("error setting up dependency '%s' for '%s': %w", dep, mod.name, err) + if err := app.setupSingle(logger, depmod); err != nil { + return fmt.Errorf("error setting up dependency %s for %s: %w", depmod, mod, err) } } } @@ -218,7 +237,7 @@ func (app *Lifecycle) setupSingle(mod *Module, logger *slog.Logger) error { if mod.setup != nil { // Run the setup function for the module if err := mod.setup(mod); err != nil { - return fmt.Errorf("error initializing '%s': %w", mod.name, err) + return fmt.Errorf("error initializing %s: %w", mod, err) } } @@ -238,13 +257,13 @@ func (app *Lifecycle) teardownSingle(mod *Module) error { // Check if the module was set up if _, ok := app.setupTracker[mod.name]; !ok { - return fmt.Errorf("module '%s' is not set up, cannot tear down", mod.name) + return fmt.Errorf("module %s is not set up, cannot tear down", mod) } // Run the teardown function for the module if mod.teardown != nil { if err := mod.teardown(mod); err != nil { - return fmt.Errorf("error tearing down '%s': %w", mod.name, err) + return fmt.Errorf("error tearing down %s: %w", mod, err) } } diff --git a/lifecycle_test.go b/lifecycle_test.go index ba5b2b3..3c6a0c1 100644 --- a/lifecycle_test.go +++ b/lifecycle_test.go @@ -3,6 +3,7 @@ package app import ( "errors" "log/slog" + "os" "testing" "github.com/stretchr/testify/assert" @@ -11,20 +12,20 @@ import ( func TestNewLifecycle(t *testing.T) { assert := assert.New(t) + // Create a blank Lifecycle instance lc := NewLifecycle() assert.NotNil(lc, "expected Lifecycle instance to be created") - assert.Equal(slog.Default(), lc.logger, "expected default logger to be set") -} - -func TestNewLifecycleL(t *testing.T) { - assert := assert.New(t) + assert.Equal(slog.Default(), lc.Logger(), "expected default logger to be available") // Create a blank Lifecycle instance with a custom logger - logger := slog.Default() - lc := NewLifecycleL(logger) + handler := slog.NewTextHandler(os.Stderr, nil) + logger := slog.New(handler) + lc = NewLifecycle().WithOpts(LifecycleOpts{ + Logger: logger, + }) assert.NotNil(lc) - assert.Equal(logger, lc.logger, "expected logger to be set") + assert.Equal(logger, lc.Logger(), "expected logger to match") assert.Len(lc.modules, 0, "expected modules to be empty") assert.Len(lc.setupTracker, 0, "expected setup tracker to be empty") assert.Len(lc.teardownTracker, 0, "expected teardown tracker to be empty") @@ -33,15 +34,15 @@ func TestNewLifecycleL(t *testing.T) { mod1 := NewModule("module1", ModuleOpts{}) mod2 := NewModule("module2", ModuleOpts{}) - lcWithModules := NewLifecycleL(logger, mod1, mod2) + lcWithModules := NewLifecycle(mod1, mod2) assert.NotNil(lcWithModules, "expected Lifecycle with modules to be created") assert.Contains(lcWithModules.modules, mod1, "expected module1 to be included") assert.Contains(lcWithModules.modules, mod2, "expected module2 to be included") assert.Len(lcWithModules.modules, 2, "expected two modules to be present") // Test for duplicate module names - assert.PanicsWithValue("duplicate module name: module1", func() { - NewLifecycleL(logger, mod1, mod1) + assert.PanicsWithValue("duplicate module: [module1]", func() { + NewLifecycle(mod1, mod1) }, "expected panic on duplicate module names") } @@ -57,17 +58,25 @@ func TestLifecycle_WithOpts(t *testing.T) { assert.Equal(opts, lc.opts, "expected Lifecycle to have the provided options") } +func TestLifecycle_WithLogger(t *testing.T) { + assert := assert.New(t) + + lc := NewLifecycle() + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + lc.WithLogger(logger) + assert.Equal(logger, lc.Logger(), "expected Lifecycle to have the provided logger") +} + func TestLifecycle_Logger(t *testing.T) { assert := assert.New(t) lc := NewLifecycle() assert.Equal(slog.Default(), lc.Logger(), "expected Logger to return a logger") - // Test panic when logger is not initialized - lc.logger = nil - assert.PanicsWithValue("lifecycle logger is not initialized", func() { - lc.Logger() - }, "expected Logger to panic when logger is not set") + // Test that custom logger is used + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + lc.opts.Logger = logger + assert.Equal(logger, lc.Logger()) } func TestLifecycle_Setup(t *testing.T) { @@ -190,6 +199,7 @@ func TestLifecycle_Teardown(t *testing.T) { // Fake setup for all modules for _, mod := range tc.modules { + mod.loaded = true // Mark as loaded lc.setupTracker[mod.name] = 0 // Mark as set up } @@ -200,7 +210,8 @@ func TestLifecycle_Teardown(t *testing.T) { for _, mod := range tc.modules { if mod.teardown != nil { _, ok := lc.teardownTracker[mod.name] - assert.True(ok, "expected module to be marked as torn down") + assert.Truef(ok, "expected module %s to be marked as torn down", mod) + assert.Falsef(mod.loaded, "expected module %s to be unloaded after teardown", mod) } } } else { @@ -218,22 +229,33 @@ func TestLifecycle_Require(t *testing.T) { mod := NewModule("module1", ModuleOpts{ Setup: func(m *Module) error { return nil }, }) - lc.Require(mod) - assert.Equal(t, lc.logger, mod.logger, "expected module logger to match lifecycle logger") + err := lc.Require(mod) + assert.NoError(t, err, "expected Require to succeed") + assert.Contains(t, lc.setupTracker, mod.name, "expected module to be marked as set up") + assert.Nil(t, mod.logger, "expected module logger to be nil") + assert.Equal(t, lc.Logger(), mod.Logger(), "expected module logger to match lifecycle logger") } func TestLifecycle_RequireUnique(t *testing.T) { + assert := assert.New(t) lc := NewLifecycle() + mod := NewModule("module1", ModuleOpts{}) err := lc.RequireUnique(mod) - assert.NoError(t, err, "expected RequireL to succeed") - assert.Equal(t, lc.logger, mod.logger, "expected module logger to match lifecycle logger") + assert.NoError(err, "expected RequireL to succeed") + assert.Nil(mod.logger, "expected module logger to be nil") + + // Requiring the same module again should fail + err = lc.RequireUnique(mod) + assert.Error(err, "expected RequireUnique to fail on duplicate module") } func TestLifecycle_RequireL(t *testing.T) { + // Create a logger for the test cases + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cases := []struct { name string - logger *slog.Logger modules []*Module existingModules []*Module expectedErr string @@ -245,7 +267,7 @@ func TestLifecycle_RequireL(t *testing.T) { { name: "nil module", modules: []*Module{nil}, - expectedErr: "module is nil", + expectedErr: "is nil", }, { name: "basic module", @@ -309,21 +331,16 @@ func TestLifecycle_RequireL(t *testing.T) { lc := NewLifecycle(tc.existingModules...) - l := tc.logger - if l == nil { - l = lc.logger - } - - err := lc.RequireL(tc.logger, tc.modules...) + err := lc.RequireL(logger, tc.modules...) if tc.expectedErr == "" { assert.NoError(err, "expected RequireL to succeed") - for _, mod := range tc.modules { + for i, mod := range tc.modules { if mod != nil { _, ok := lc.setupTracker[mod.name] - assert.True(ok, "expected module to be marked as set up") - assert.True(mod.loaded, "expected module to be loaded") - assert.Equal(l, mod.logger, "expected module logger to match") + assert.Truef(ok, "expected module %d %s to be marked as set up", i, mod) + assert.Truef(mod.loaded, "expected module %d %s to be loaded", i, mod) + assert.Equalf(logger, mod.logger, "expected module %d %s logger to match", i, mod) } } } else { @@ -335,6 +352,9 @@ func TestLifecycle_RequireL(t *testing.T) { } func TestLifecycle_RequireUniqueL(t *testing.T) { + // Create a logger for the test cases + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cases := []struct { name string modules []*Module @@ -354,12 +374,12 @@ func TestLifecycle_RequireUniqueL(t *testing.T) { NewModule("module2", ModuleOpts{}), }, existingModules: []string{"module1"}, - expectedErr: "module 'module1' is already set up, cannot require it again", + expectedErr: "module [module1] is already set up, cannot require it again", }, { name: "nil module", modules: []*Module{nil}, - expectedErr: "module is nil", + expectedErr: "is nil", }, } @@ -373,7 +393,7 @@ func TestLifecycle_RequireUniqueL(t *testing.T) { lc.setupTracker[modName] = 0 // Mark as set up } - err := lc.RequireUniqueL(lc.logger, tc.modules...) + err := lc.RequireUniqueL(logger, tc.modules...) if tc.expectedErr == "" { assert.NoError(err, "expected RequireUniqueL to succeed") } else { @@ -385,6 +405,9 @@ func TestLifecycle_RequireUniqueL(t *testing.T) { } func TestLifecycle_setupSingle(t *testing.T) { + // Create a logger for the test cases + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + cases := []struct { name string lc *Lifecycle @@ -447,7 +470,7 @@ func TestLifecycle_setupSingle(t *testing.T) { Setup: func(m *Module) error { return errors.New("dependency setup failed") }, }), }, - expectedErr: "error initializing 'errorModule'", + expectedErr: "dependency setup failed", }, { name: "dependency without autoload", @@ -472,7 +495,7 @@ func TestLifecycle_setupSingle(t *testing.T) { l := tc.logger if l == nil { - l = slog.Default() + l = logger } lc := tc.lc @@ -480,20 +503,19 @@ func TestLifecycle_setupSingle(t *testing.T) { lc = NewLifecycle(tc.modules...) } - err := lc.setupSingle(tc.targetModule, l) - - if tc.targetModule != nil { - assert.Equal(l, tc.targetModule.logger, "expected module logger to match") - } + err := lc.setupSingle(l, tc.targetModule) if tc.expectedErr == "" { assert.NoError(err, "expected no error from setupSingle") } else { assert.Contains(err.Error(), tc.expectedErr, "expected error message to match") } + + if tc.targetModule != nil { + assert.Equal(l, tc.targetModule.logger, "expected module logger to match") + } }) } - } func TestLifecycle_teardownSingle(t *testing.T) { diff --git a/module.go b/module.go index 372051e..48859a6 100644 --- a/module.go +++ b/module.go @@ -13,15 +13,23 @@ var ErrModuleNotFound = errors.New("module not found") // moduleFn is a function type for module setup and teardown functions. type moduleFn func(*Module) error +// GenericModule is an interface that allows modules to be extended with custom +// functionality, as the module can return a pointer to the underlying Module. +type GenericModule interface { + // Module returns the underlying Module instance. + Module() *Module +} + // Module represents a sub-system of the application, with its setup and // teardown functions and dependencies. type Module struct { - logger *slog.Logger - name string - setup moduleFn - teardown moduleFn - depends []string - loaded bool // loaded indicates if the module has been set up + lifecycle *Lifecycle // lifecycle is the parent lifecycle this module belongs to + logger *slog.Logger + name string + setup moduleFn + teardown moduleFn + depends []string + loaded bool // loaded indicates if the module has been set up } // ModuleOpts contains user-exposed options when defining a module. @@ -45,12 +53,13 @@ func NewModule(name string, opts ModuleOpts) *Module { } } -// Logger returns the logger for the module. +// Logger returns the logger for the module. Uses the lifecycle's logger unless +// a specific logger has been set during module load. func (s *Module) Logger() *slog.Logger { - if s.logger == nil { - panic(fmt.Sprintf("module %s used before logger was initialized", s.name)) + if s.logger != nil { + return s.logger } - return s.logger + return s.lifecycle.Logger() } // Name returns the name of the module. @@ -58,6 +67,11 @@ func (s *Module) Name() string { return s.name } +// String returns the name of the module in square brackets, e.g. "[module-name]". +func (s *Module) String() string { + return fmt.Sprintf("[%s]", s.name) +} + // Loaded returns whether the module has been set up. func (s *Module) Loaded() bool { return s.loaded