├── inspect ├── Taskfile.yaml ├── static.go ├── services.go ├── static │ ├── tree.css │ ├── tree.html │ └── tree.js ├── tree_json.go └── inspect.go ├── .gitignore ├── generate.go ├── .tool-versions ├── examples ├── hooks │ ├── README.md │ ├── pinger │ │ ├── pinger.go │ │ └── services.go │ ├── server │ │ ├── services.go │ │ └── server.go │ └── main.go ├── web │ ├── core │ │ └── interfaces.go │ ├── server │ │ ├── services.go │ │ └── server.go │ ├── pinger │ │ ├── services.go │ │ └── pinger.go │ ├── README.md │ └── main.go ├── factories │ ├── README.md │ ├── pinger.go │ ├── ticker.go │ └── main.go └── cli │ ├── README.md │ ├── pinger.go │ ├── ticker.go │ └── main.go ├── run_config.go ├── service_factory.go ├── .mockery.yml ├── .github ├── dependabot.yml └── workflows │ └── ci.yml ├── service_list.go ├── lifecycle_hooks.go ├── service_runner.go ├── config.go ├── .golangci.yaml ├── Taskfile.yaml ├── .cursor └── rules │ └── tests.mdc ├── LICENSE ├── tags.go ├── utils.go ├── service_typed.go ├── example_container_test.go ├── service_factory0.go ├── common_test.go ├── healthcheck_server.go ├── service_factory1.go ├── example_pal_test.go ├── service_factory2.go ├── runners.go ├── service_factory3.go ├── errors.go ├── service_factory4.go ├── service_fn_singleton.go ├── service_factory5.go ├── interfaces.go ├── go.mod ├── service_const_test.go ├── service_const.go ├── services.go ├── lifecycle_interfaces.go ├── tags_test.go ├── service_factory1_test.go ├── pkg └── dag │ ├── dag.go │ └── dag_test.go ├── runners_test.go ├── container_test.go ├── pal.go ├── hook_priority_test.go ├── pal_test.go ├── container.go ├── go.sum ├── api.go └── api_test.go /inspect/Taskfile.yaml: -------------------------------------------------------------------------------- 1 | ../Taskfile.yaml -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.svg 2 | *.gv 3 | coverage/ 4 | mocks_test.go 5 | -------------------------------------------------------------------------------- /generate.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | //go:generate go tool mockery 4 | -------------------------------------------------------------------------------- /.tool-versions: -------------------------------------------------------------------------------- 1 | golang 1.24.2 2 | golangci-lint 2.1.2 3 | task 3.42.1 4 | -------------------------------------------------------------------------------- /inspect/static.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "embed" 5 | ) 6 | 7 | //go:embed static 8 | var StaticFS embed.FS 9 | -------------------------------------------------------------------------------- /examples/hooks/README.md: -------------------------------------------------------------------------------- 1 | # Pal web app example with hooks 2 | 3 | Just like the web example, but using hooks: `ToInit` and `ToShutdown` 4 | -------------------------------------------------------------------------------- /run_config.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | var ( 4 | defaultRunConfig = &RunConfig{ 5 | Wait: true, 6 | } 7 | ) 8 | 9 | type RunConfig struct { 10 | Wait bool 11 | } 12 | -------------------------------------------------------------------------------- /examples/web/core/interfaces.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "context" 4 | 5 | // Pinger is an interface for the pinger service. 6 | type Pinger interface { 7 | Ping(ctx context.Context) error 8 | } 9 | -------------------------------------------------------------------------------- /examples/factories/README.md: -------------------------------------------------------------------------------- 1 | # Pal cli app example 2 | 3 | A simple CLI app that pings a url on timer. It utilizes pal's dependency lifecycle managemant: 4 | `Init`, `Shutdown` and `Run`, demostrates automatic logger injection and demonstrates how to use factories. 5 | 6 | To run: 7 | `go run .` 8 | -------------------------------------------------------------------------------- /examples/web/server/services.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "github.com/zhulik/pal" 5 | ) 6 | 7 | // Provide provides the server service. 8 | func Provide() pal.ServiceDef { 9 | return pal.ProvideList( 10 | pal.Provide(&Server{}), 11 | // Add more services here, if needed. 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /examples/cli/README.md: -------------------------------------------------------------------------------- 1 | # Pal cli app example 2 | 3 | A simple CLI app that pings google.com on timer. It utilizes pal's dependency lifecycle managemant: 4 | `Init`, `Shutdown` and `Run`, demostrates automatic logger injection and dependecy injection using an interface as 5 | a dependecy identifier. 6 | 7 | To run: 8 | `go run .` 9 | -------------------------------------------------------------------------------- /inspect/services.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "github.com/zhulik/pal" 5 | ) 6 | 7 | const ( 8 | defaultPort = 24242 9 | ) 10 | 11 | func Provide(port ...int) pal.ServiceDef { 12 | p := defaultPort 13 | if len(port) > 0 { 14 | p = port[0] 15 | } 16 | 17 | return pal.ProvideList( 18 | pal.Provide(&Inspect{port: p}), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /examples/web/pinger/services.go: -------------------------------------------------------------------------------- 1 | package pinger 2 | 3 | import ( 4 | "github.com/zhulik/pal" 5 | "github.com/zhulik/pal/examples/web/core" 6 | ) 7 | 8 | // Provide provides the pinger service. 9 | func Provide() pal.ServiceDef { 10 | return pal.ProvideList( 11 | pal.Provide[core.Pinger](&Pinger{}), 12 | // Add more services here, if needed. 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /service_factory.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import "reflect" 4 | 5 | type ServiceFactory[I any, T any] struct { 6 | ServiceTyped[I] 7 | } 8 | 9 | // Make is a no-op for factory services as they are created on demand. 10 | func (c *ServiceFactory[I, T]) Make() any { 11 | var t T 12 | typ := reflect.TypeOf(t).Elem() 13 | return reflect.New(typ).Interface().(I) 14 | } 15 | -------------------------------------------------------------------------------- /.mockery.yml: -------------------------------------------------------------------------------- 1 | dir: '{{.InterfaceDir}}' 2 | filename: mocks_test.go 3 | structname: '{{.Mock}}{{.InterfaceName}}' 4 | pkgname: '{{.SrcPackageName}}_test' 5 | template: testify 6 | packages: 7 | github.com/zhulik/pal: 8 | interfaces: 9 | ServiceDef: 10 | Invoker: 11 | Runner: 12 | RunConfiger: 13 | Initer: 14 | Shutdowner: 15 | HealthChecker: 16 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "go" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | 8 | - package-ecosystem: "go" 9 | directory: "/inspect" 10 | schedule: 11 | interval: "weekly" 12 | 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | interval: "weekly" 18 | -------------------------------------------------------------------------------- /service_list.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceList is a proxy service to a list of services. 9 | type ServiceList struct { 10 | ServiceTyped[any] 11 | Services []ServiceDef 12 | } 13 | 14 | func (s *ServiceList) Dependencies() []ServiceDef { 15 | return s.Services 16 | } 17 | 18 | func (s *ServiceList) Instance(_ context.Context, _ ...any) (any, error) { 19 | return nil, nil 20 | } 21 | 22 | func (s *ServiceList) Name() string { 23 | return fmt.Sprintf("$service-list-%s", randomID()) 24 | } 25 | -------------------------------------------------------------------------------- /examples/web/README.md: -------------------------------------------------------------------------------- 1 | # Pal web app example 2 | 3 | A simple web app that pings google.com from an http endpoint handler. It utilizes pal's dependency lifecycle managemant: 4 | `Init`, `Shutdown` and `Run`, demostrates automatic logger injection, embedded healthcheck server and dependecy 5 | injection using an interface as 6 | a dependecy identifier. 7 | 8 | It also demostrates 2 important concepts: 9 | 10 | - Managing a services which do not natively support stopping with a context: `http.Server` 11 | - Modularity: it shows how to split the app into multiple modules 12 | -------------------------------------------------------------------------------- /examples/hooks/pinger/pinger.go: -------------------------------------------------------------------------------- 1 | package pinger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | ) 8 | 9 | // Pinger is a concrete implementation of the Pinger interface. 10 | type Pinger struct { 11 | Logger *slog.Logger 12 | 13 | client *http.Client 14 | } 15 | 16 | // Ping pings google.com. 17 | func (p *Pinger) Ping(ctx context.Context) error { 18 | req, err := http.NewRequestWithContext(ctx, "GET", "https://google.com", nil) 19 | if err != nil { 20 | return err 21 | } 22 | resp, err := p.client.Do(req) 23 | if err != nil { 24 | return err 25 | } 26 | p.Logger.Info("GET google.com", "status", resp.Status) 27 | return resp.Body.Close() 28 | } 29 | -------------------------------------------------------------------------------- /lifecycle_hooks.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import "context" 4 | 5 | // LifecycleHook is a function type that can be registered to run at specific points in a service's lifecycle. 6 | // It receives the service instance, a context, and the Pal instance, and can return an error to indicate failure. 7 | // These hooks are typically used with ToInit methods to customize service initialization. 8 | type LifecycleHook[T any] func(ctx context.Context, service T, pal *Pal) error 9 | 10 | // LifecycleHooks is a collection of hooks that can be registered to run at specific points in a service's lifecycle. 11 | type LifecycleHooks[T any] struct { 12 | Init LifecycleHook[T] 13 | Shutdown LifecycleHook[T] 14 | HealthCheck LifecycleHook[T] 15 | } 16 | -------------------------------------------------------------------------------- /examples/hooks/pinger/services.go: -------------------------------------------------------------------------------- 1 | package pinger 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | // Provide provides the pinger service. 12 | func Provide() pal.ServiceDef { 13 | return pal.ProvideList( 14 | pal.Provide(&Pinger{}). 15 | ToInit(func(_ context.Context, pinger *Pinger, _ *pal.Pal) error { 16 | defer pinger.Logger.Info("Pinger initialized") 17 | 18 | pinger.client = &http.Client{ 19 | Timeout: 5 * time.Second, 20 | } 21 | 22 | return nil 23 | }). 24 | ToShutdown(func(_ context.Context, pinger *Pinger, _ *pal.Pal) error { 25 | defer pinger.Logger.Info("Pinger shut down") 26 | pinger.client.CloseIdleConnections() 27 | 28 | return nil 29 | }), 30 | // Add more services here, if needed. 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /service_runner.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | ) 8 | 9 | const idCharset = "abcdefghijklmnopqrstuvwxyz0123456789" 10 | 11 | type ServiceRunner struct { 12 | ServiceTyped[any] 13 | fn func(ctx context.Context) error 14 | } 15 | 16 | func (c *ServiceRunner) RunConfig() *RunConfig { 17 | return defaultRunConfig 18 | } 19 | 20 | func (c *ServiceRunner) Run(ctx context.Context) error { 21 | return c.fn(ctx) 22 | } 23 | 24 | func (c *ServiceRunner) Instance(_ context.Context, _ ...any) (any, error) { 25 | return nil, nil 26 | } 27 | 28 | func (c *ServiceRunner) Name() string { 29 | return fmt.Sprintf("$function-runner-%s", randomID()) 30 | } 31 | 32 | func randomID() string { 33 | b := make([]byte, 8) 34 | for i := range b { 35 | b[i] = idCharset[rand.Intn(len(idCharset))] // nolint:gosec 36 | } 37 | 38 | return string(b) 39 | } 40 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/go-playground/validator/v10" 8 | ) 9 | 10 | var ( 11 | configValidator = validator.New() 12 | ) 13 | 14 | // SlogAttributeSetter is a function that returns the name and value for the attribute to be added to the slog.Logger. 15 | // It receives the target struct and returns the name and value for the attribute. Called when logger is being injected. 16 | type SlogAttributeSetter func(target any) (string, string) 17 | 18 | // Config is the configuration for pal. 19 | type Config struct { 20 | InitTimeout time.Duration `validate:"gt=0"` 21 | HealthCheckTimeout time.Duration `validate:"gt=0"` 22 | ShutdownTimeout time.Duration `validate:"gt=0"` 23 | 24 | AttrSetters []SlogAttributeSetter 25 | } 26 | 27 | func (c *Config) Validate(_ context.Context) error { 28 | return configValidator.Struct(c) 29 | } 30 | -------------------------------------------------------------------------------- /examples/hooks/server/services.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | 7 | "github.com/zhulik/pal" 8 | ) 9 | 10 | // Provide provides the server service. 11 | func Provide() pal.ServiceDef { 12 | return pal.ProvideList( 13 | pal.Provide(&Server{}).ToInit(func(_ context.Context, server *Server, _ *pal.Pal) error { 14 | defer server.Logger.Info("Server initialized") 15 | 16 | server.server = &http.Server{ //nolint:gosec 17 | Addr: ":8080", 18 | } 19 | 20 | server.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 | if err := server.Pinger.Ping(r.Context()); err != nil { 22 | http.Error(w, err.Error(), http.StatusInternalServerError) 23 | return 24 | } 25 | w.WriteHeader(http.StatusOK) 26 | w.Write([]byte("pong")) //nolint:errcheck 27 | }) 28 | 29 | return nil 30 | }), 31 | // Add more services here, if needed. 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | linters: 4 | enable: 5 | - gosec 6 | - misspell 7 | - revive 8 | - unconvert 9 | - unparam 10 | - whitespace 11 | - paralleltest 12 | settings: 13 | gosec: 14 | excludes: 15 | - G104 16 | exclusions: 17 | generated: lax 18 | presets: 19 | - comments 20 | - common-false-positives 21 | - legacy 22 | - std-error-handling 23 | rules: 24 | - linters: 25 | - gosec 26 | path: _test\.go 27 | paths: 28 | - third_party$ 29 | - builtin$ 30 | - examples$ 31 | issues: 32 | max-issues-per-linter: 0 33 | max-same-issues: 0 34 | fix: false 35 | formatters: 36 | enable: 37 | - gofmt 38 | - goimports 39 | settings: 40 | goimports: 41 | local-prefixes: 42 | - github.com/zhulik/pips 43 | exclusions: 44 | generated: lax 45 | paths: 46 | - third_party$ 47 | - builtin$ 48 | - examples$ 49 | -------------------------------------------------------------------------------- /examples/hooks/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/zhulik/pal/examples/web/core" 9 | ) 10 | 11 | // Server is a simple http Server that calls Pinger.Ping on each request. 12 | type Server struct { 13 | Pinger core.Pinger 14 | Logger *slog.Logger 15 | 16 | server *http.Server 17 | } 18 | 19 | // Run runs the server. 20 | func (s *Server) Run(ctx context.Context) error { 21 | s.Logger.Info("Server running on :8080. Run `curl http://localhost:8080/` to see it in action.") 22 | 23 | // We don't use Shutdown here because ListenAndServe() does not natively support context. 24 | // instead we use a goroutine to listen for the context done signal and shutdown the server. 25 | go func() { 26 | <-ctx.Done() 27 | s.server.Shutdown(context.Background()) //nolint:errcheck 28 | }() 29 | 30 | if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 31 | return err 32 | } 33 | 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /Taskfile.yaml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | tasks: 3 | default: 4 | desc: Run tests and linting 5 | deps: 6 | - lint-fix 7 | - test 8 | 9 | generate: 10 | desc: Generate code 11 | cmds: 12 | - go generate ./... 13 | 14 | check: 15 | deps: 16 | - lint 17 | - test 18 | 19 | test: 20 | desc: Run tests 21 | cmds: 22 | - go test -race ./... -test.timeout=3s -count=1 23 | 24 | cover: 25 | desc: Run tests with cover report 26 | cmds: 27 | - mkdir -p coverage/ 28 | - go test -coverpkg=./... -coverprofile=coverage/cover.out ./... -test.timeout=2s 29 | - go tool cover -html coverage/cover.out -o coverage/cover.html 30 | 31 | lint: 32 | desc: Run golangci-lint 33 | cmds: 34 | - golangci-lint run 35 | 36 | lint-fix: 37 | desc: Run golangci-lint with auto-fix 38 | cmds: 39 | - golangci-lint run --fix 40 | 41 | doc: 42 | desc: Run pkgsite 43 | cmds: 44 | - go tool golang.org/x/pkgsite/cmd/pkgsite 45 | -------------------------------------------------------------------------------- /.cursor/rules/tests.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | globs: ./**/*_test.go 3 | alwaysApply: false 4 | --- 5 | # Tests 6 | 7 | Tests must always be defined in `_test` package and only test package's public API. 8 | 9 | When testing functions, each function must have a separate Test function, for instance: 10 | Function `Foo` has test `TestFoo(t *testing.T)`. Each test case is defined inside it in a separate subtest with `t.Run()`. 11 | 12 | When testing structs, every method of the struct must have a separate Test function, for instance: 13 | Struct `Foo` has a method `Bar`, tests for this method should be defined in `TestFoo_Bar(t *testing.T)`. 14 | Each test case is defined inside it in a separate subtest with `t.Run()`. 15 | 16 | Read-only resources which can be reused across multiple tests, should be defined as global variables. 17 | Resources which can be reused across multiple tests files, must be defined in common_test.go 18 | 19 | For asserts and mocks `github.com/stretchr/testify` should be used. 20 | 21 | Make sure all code paths are tested. 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ "**" ] 6 | 7 | jobs: 8 | lint: 9 | name: Lint 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Set up Go 16 | uses: actions/setup-go@v5 17 | with: 18 | cache: true 19 | 20 | - name: Run generate 21 | run: go generate ./... 22 | 23 | - name: Run linter 24 | uses: golangci/golangci-lint-action@v7 25 | with: 26 | version: latest 27 | args: --timeout=2m 28 | 29 | test: 30 | name: Test 31 | runs-on: ubuntu-latest 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Set up Go 37 | uses: actions/setup-go@v5 38 | with: 39 | cache: true 40 | 41 | - name: Run generate 42 | run: go generate ./... 43 | 44 | - name: Run tests 45 | run: go test -race ./... 46 | 47 | ci: 48 | name: CI 49 | runs-on: ubuntu-latest 50 | needs: 51 | - lint 52 | - test 53 | steps: 54 | - run: echo ok 55 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) [year] [fullname] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /examples/cli/pinger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // pinger is a concrete implementation of the Pinger interface. 11 | type pinger struct { 12 | Logger *slog.Logger 13 | 14 | client *http.Client 15 | } 16 | 17 | // Init initializes the pinger service, creates a http client. 18 | func (p *pinger) Init(_ context.Context) error { 19 | defer p.Logger.Info("Pinger initialized") 20 | 21 | p.client = &http.Client{ 22 | Timeout: 5 * time.Second, 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // Shutdown closes the http client. 29 | func (p *pinger) Shutdown(_ context.Context) error { 30 | defer p.Logger.Info("Pinger shut down") 31 | p.client.CloseIdleConnections() 32 | 33 | return nil 34 | } 35 | 36 | // Ping pings google.com. 37 | func (p *pinger) Ping(ctx context.Context) error { 38 | req, err := http.NewRequestWithContext(ctx, "GET", "https://google.com", nil) 39 | if err != nil { 40 | return err 41 | } 42 | resp, err := p.client.Do(req) 43 | if err != nil { 44 | return err 45 | } 46 | p.Logger.Info("GET google.com", "status", resp.Status) 47 | return resp.Body.Close() 48 | } 49 | -------------------------------------------------------------------------------- /examples/factories/pinger.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // pinger is a concrete implementation of the Pinger interface. 11 | type pinger struct { 12 | URL string 13 | Logger *slog.Logger 14 | 15 | client *http.Client 16 | } 17 | 18 | // Init initializes the pinger service, creates a http client. 19 | func (p *pinger) Init(_ context.Context) error { 20 | defer p.Logger.Info("Pinger initialized") 21 | 22 | p.client = &http.Client{ 23 | Timeout: 5 * time.Second, 24 | } 25 | 26 | return nil 27 | } 28 | 29 | // Shutdown closes the http client. 30 | func (p *pinger) Shutdown(_ context.Context) error { 31 | defer p.Logger.Info("Pinger shut down") 32 | p.client.CloseIdleConnections() 33 | 34 | return nil 35 | } 36 | 37 | // Ping pings given URL. 38 | func (p *pinger) Ping(ctx context.Context) error { 39 | req, err := http.NewRequestWithContext(ctx, "GET", p.URL, nil) 40 | if err != nil { 41 | return err 42 | } 43 | resp, err := p.client.Do(req) 44 | if err != nil { 45 | return err 46 | } 47 | p.Logger.Info("GET "+p.URL, "status", resp.Status) 48 | return resp.Body.Close() 49 | } 50 | -------------------------------------------------------------------------------- /examples/web/pinger/pinger.go: -------------------------------------------------------------------------------- 1 | package pinger 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | // Pinger is a concrete implementation of the Pinger interface. 11 | type Pinger struct { 12 | Logger *slog.Logger 13 | 14 | client *http.Client 15 | } 16 | 17 | // Init initializes the pinger service, creates a http client. 18 | func (p *Pinger) Init(_ context.Context) error { 19 | defer p.Logger.Info("Pinger initialized") 20 | 21 | p.client = &http.Client{ 22 | Timeout: 5 * time.Second, 23 | } 24 | 25 | return nil 26 | } 27 | 28 | // Shutdown closes the http client. 29 | func (p *Pinger) Shutdown(_ context.Context) error { 30 | defer p.Logger.Info("Pinger shut down") 31 | p.client.CloseIdleConnections() 32 | 33 | return nil 34 | } 35 | 36 | // Ping pings google.com. 37 | func (p *Pinger) Ping(ctx context.Context) error { 38 | req, err := http.NewRequestWithContext(ctx, "GET", "https://google.com", nil) 39 | if err != nil { 40 | return err 41 | } 42 | resp, err := p.client.Do(req) 43 | if err != nil { 44 | return err 45 | } 46 | p.Logger.Info("GET google.com", "status", resp.Status) 47 | return resp.Body.Close() 48 | } 49 | -------------------------------------------------------------------------------- /tags.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type Tag string 9 | 10 | const ( 11 | TagSkip Tag = "skip" 12 | TagMatchInterface Tag = "match_interface" 13 | TagName Tag = "name" 14 | ) 15 | 16 | var supportedTags = map[Tag]bool{ 17 | TagSkip: true, 18 | TagMatchInterface: true, 19 | TagName: true, 20 | } 21 | 22 | func ParseTag(tags string) (map[Tag]string, error) { 23 | tagMap := make(map[Tag]string) 24 | tags = strings.ReplaceAll(tags, " ", "") 25 | if tags == "" { 26 | return tagMap, nil 27 | } 28 | 29 | for tag := range strings.SplitSeq(tags, ",") { 30 | parts := strings.Split(tag, "=") 31 | tagName := parts[0] 32 | 33 | if !supportedTags[Tag(tagName)] { 34 | return nil, fmt.Errorf("%w: tag unsupported %s", ErrInvalidTag, tagName) 35 | } 36 | switch len(parts) { 37 | case 2: 38 | if parts[1] == "" { 39 | return nil, fmt.Errorf("%w: tag is malformed %s", ErrInvalidTag, tag) 40 | } 41 | tagMap[Tag(tagName)] = parts[1] 42 | case 1: 43 | tagMap[Tag(tagName)] = "" 44 | default: 45 | return nil, fmt.Errorf("%w: tag is malformed %s", ErrInvalidTag, tag) 46 | } 47 | } 48 | return tagMap, nil 49 | } 50 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | "runtime" 7 | "strings" 8 | ) 9 | 10 | func empty[T any]() T { 11 | var t T 12 | return t 13 | } 14 | 15 | func isNil(val any) bool { 16 | v := reflect.ValueOf(val) 17 | return !v.IsValid() || (v.Kind() == reflect.Ptr && v.IsNil()) || v.IsZero() 18 | } 19 | 20 | func tryWrap(f func() error) func() error { 21 | return func() (err error) { 22 | defer func() { 23 | if r := recover(); r != nil { 24 | switch x := r.(type) { 25 | case error: 26 | err = x 27 | default: 28 | err = fmt.Errorf("%v", x) 29 | } 30 | 31 | err = &PanicError{ 32 | error: err, 33 | backtrace: backtrace(4), 34 | } 35 | } 36 | }() 37 | err = f() 38 | return 39 | } 40 | } 41 | 42 | func backtrace(skipLastN int) string { 43 | pc := make([]uintptr, 100) 44 | n := runtime.Callers(skipLastN, pc) 45 | 46 | pc = pc[:n] 47 | 48 | var stackTrace strings.Builder 49 | 50 | frames := runtime.CallersFrames(pc) 51 | 52 | for { 53 | frame, more := frames.Next() 54 | stackTrace.WriteString(fmt.Sprintf("%s\n\t%s:%d\n", frame.Function, frame.File, frame.Line)) 55 | 56 | if !more { 57 | break 58 | } 59 | } 60 | 61 | return stackTrace.String() 62 | } 63 | -------------------------------------------------------------------------------- /examples/cli/ticker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | ) 8 | 9 | // ticker is a concrete implementation of the ticker interface. 10 | type ticker struct { 11 | Pinger Pinger // pinger is injected by pal, using the Pinger interface. 12 | Logger *slog.Logger // logger is injected by pal as is 13 | 14 | ticker *time.Ticker // ticker is created in Init and stopped in Shutdown. 15 | } 16 | 17 | // Init initializes the ticker service. 18 | func (t *ticker) Init(_ context.Context) error { //nolint:unparam 19 | t.Logger.Info("ticker initialized") 20 | 21 | t.ticker = time.NewTicker(time.Second) 22 | 23 | return nil 24 | } 25 | 26 | // Shutdown closes the ticker service. 27 | func (t *ticker) Shutdown(_ context.Context) error { //nolint:unparam 28 | t.Logger.Info("ticker shut down") 29 | 30 | t.ticker.Stop() 31 | 32 | return nil 33 | } 34 | 35 | // Run runs the ticker service, calls Pinger.Ping every second. 36 | func (t *ticker) Run(ctx context.Context) error { //nolint:unparam 37 | for { 38 | select { 39 | case <-ctx.Done(): 40 | return nil 41 | 42 | case <-t.ticker.C: 43 | if err := t.Pinger.Ping(ctx); err != nil { 44 | t.Logger.Error("Failed to ping", "error", err) 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /service_typed.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type ServiceTyped[T any] struct { 8 | P *Pal 9 | name string 10 | } 11 | 12 | func (c *ServiceTyped[T]) Dependencies() []ServiceDef { 13 | return nil 14 | } 15 | 16 | // Run is a no-op for factory services as they don't run in the background. 17 | func (c *ServiceTyped[T]) Run(_ context.Context) error { 18 | return nil 19 | } 20 | 21 | // Init is a no-op for factory services as they are created on demand. 22 | func (c *ServiceTyped[T]) Init(_ context.Context) error { 23 | return nil 24 | } 25 | 26 | // HealthCheck is a no-op for factory services as they are created on demand. 27 | func (c *ServiceTyped[T]) HealthCheck(_ context.Context) error { 28 | return nil 29 | } 30 | 31 | // Shutdown is a no-op for factory services as they are created on demand. 32 | func (c *ServiceTyped[T]) Shutdown(_ context.Context) error { 33 | return nil 34 | } 35 | 36 | func (c *ServiceTyped[T]) RunConfig() *RunConfig { 37 | return nil 38 | } 39 | 40 | // Make is a no-op for factory services as they are created on demand. 41 | func (c *ServiceTyped[T]) Make() any { 42 | var t T 43 | return t 44 | } 45 | 46 | // Name returns the name of the service, which is the type name of T. 47 | func (c *ServiceTyped[T]) Name() string { 48 | return c.name 49 | } 50 | 51 | func (c *ServiceTyped[T]) Arguments() int { 52 | return 0 53 | } 54 | -------------------------------------------------------------------------------- /example_container_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | // SimpleService is a test service interface 12 | type SimpleService interface { 13 | GetMessage() string 14 | } 15 | 16 | // SimpleServiceImpl implements SimpleService 17 | type SimpleServiceImpl struct{} 18 | 19 | // GetMessage returns a greeting message 20 | func (s *SimpleServiceImpl) GetMessage() string { 21 | return "Hello from SimpleService" 22 | } 23 | 24 | // This example demonstrates how to create a Pal instance with services and use it. 25 | func Example_container() { 26 | // Create a Pal instance with the service 27 | p := pal.New( 28 | pal.Provide[SimpleService](&SimpleServiceImpl{}), 29 | ). 30 | InitTimeout(time.Second). 31 | HealthCheckTimeout(time.Second). 32 | ShutdownTimeout(3 * time.Second) 33 | 34 | // Initialize Pal 35 | ctx := context.Background() 36 | if err := p.Init(ctx); err != nil { 37 | fmt.Printf("Failed to initialize Pal: %v\n", err) 38 | return 39 | } 40 | 41 | // Invoke the service 42 | instance, err := p.Invoke(ctx, "github.com/zhulik/pal_test.SimpleService") 43 | if err != nil { 44 | fmt.Printf("Failed to invoke service: %v\n", err) 45 | return 46 | } 47 | 48 | // Use the service 49 | service := instance.(SimpleService) 50 | fmt.Println(service.GetMessage()) 51 | 52 | // Output: Hello from SimpleService 53 | } 54 | -------------------------------------------------------------------------------- /inspect/static/tree.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | margin: 0; 5 | padding: 0; 6 | } 7 | 8 | #tree { 9 | position: absolute; 10 | top: 0; 11 | left: 0; 12 | width: 100vw; 13 | height: 100vh; 14 | border: 1px solid lightgray; 15 | box-sizing: border-box; 16 | } 17 | 18 | #options-panel { 19 | position: fixed; 20 | top: 0; 21 | right: 0; 22 | width: 25vw; 23 | height: 100vh; 24 | background: white; 25 | border-left: 1px solid #ccc; 26 | padding: 10px; 27 | box-shadow: -2px 0 10px rgba(0,0,0,0.1); 28 | z-index: 1000; 29 | box-sizing: border-box; 30 | } 31 | 32 | #options-panel label { 33 | display: block; 34 | margin-bottom: 5px; 35 | font-weight: bold; 36 | font-size: 12px; 37 | } 38 | 39 | #options-textarea { 40 | width: 100%; 41 | height: calc(100vh - 60px); 42 | font-family: monospace; 43 | font-size: 15px; 44 | border: 1px solid #ddd; 45 | border-radius: 3px; 46 | padding: 5px; 47 | resize: none; 48 | box-sizing: border-box; 49 | } 50 | 51 | /* Node table styles */ 52 | #node-table-template { 53 | display: none; 54 | } 55 | 56 | .node-table { 57 | border-collapse: collapse; 58 | font-family: monospace; 59 | font-size: 12px; 60 | } 61 | 62 | .node-table td { 63 | border: 1px solid #ccc; 64 | padding: 4px; 65 | } 66 | 67 | .node-table-header { 68 | background-color: #f5f5f5; 69 | font-weight: bold; 70 | } -------------------------------------------------------------------------------- /examples/hooks/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhulik/pal" 8 | "github.com/zhulik/pal/examples/web/pinger" 9 | "github.com/zhulik/pal/examples/web/server" 10 | ) 11 | 12 | // main is the entry point of the program. 13 | func main() { 14 | // Create a new pal application, provide the services and initialize pal's lifecycle timeouts. 15 | // Pal is aware of the dependencies between the services and initlizes them in correct order: 16 | // first pinger, then server. After initialization, it runs the runners, in this case the server 17 | // When shutting down, it first shuts down the server, then the pinger. First it stops the runners, 18 | // then shuts down the services in the order reversed to the initialization. 19 | p := pal.New( 20 | pinger.Provide(), // Provide services from the pinger module. 21 | server.Provide(), // Provide services from the server module. 22 | ). 23 | InjectSlog(). // Enables automatic logger injection. 24 | RunHealthCheckServer(":8081", "/healthz"). // Run the health check server. 25 | InitTimeout(time.Second). // Set the timeout for the initialization phase. 26 | HealthCheckTimeout(time.Second). // Set the timeout for the health check phase. 27 | ShutdownTimeout(3 * time.Second) // Set the timeout for the shutdown phase. 28 | 29 | if err := p.Run(context.Background()); err != nil { // Run the application. 30 | panic(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/web/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhulik/pal" 8 | "github.com/zhulik/pal/examples/web/pinger" 9 | "github.com/zhulik/pal/examples/web/server" 10 | ) 11 | 12 | // main is the entry point of the program. 13 | func main() { 14 | // Create a new pal application, provide the services and initialize pal's lifecycle timeouts. 15 | // Pal is aware of the dependencies between the services and initlizes them in correct order: 16 | // first pinger, then server. After initialization, it runs the runners, in this case the server 17 | // When shutting down, it first shuts down the server, then the pinger. First it stops the runners, 18 | // then shuts down the services in the order reversed to the initialization. 19 | p := pal.New( 20 | pinger.Provide(), // Provide services from the pinger module. 21 | server.Provide(), // Provide services from the server module. 22 | ). 23 | InjectSlog(). // Enables automatic logger injection. 24 | RunHealthCheckServer(":8081", "/healthz"). // Run the health check server. 25 | InitTimeout(time.Second). // Set the timeout for the initialization phase. 26 | HealthCheckTimeout(time.Second). // Set the timeout for the health check phase. 27 | ShutdownTimeout(3 * time.Second) // Set the timeout for the shutdown phase. 28 | 29 | if err := p.Run(context.Background()); err != nil { // Run the application. 30 | panic(err) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /examples/cli/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhulik/pal" 8 | ) 9 | 10 | // Pinger is an interface for the pinger service. 11 | type Pinger interface { 12 | Ping(ctx context.Context) error 13 | } 14 | 15 | // main is the entry point of the program. 16 | func main() { 17 | // Create a new pal application, provide the services and initialize pal's lifecycle timeouts. 18 | // Pal is aware of the dependencies between the services and initlizes them in correct order: 19 | // first pinger, then ticker. After initialization, it runs the runners, in this case the ticker 20 | // When shutting down, it first shuts down the ticker, then the pinger. First it stops the runners, 21 | // then shuts down the services in the order reversed to the initialization. 22 | p := pal.New( 23 | pal.Provide[Pinger](&pinger{}), // Provide the pinger service as the Pinger interface. 24 | pal.Provide(&ticker{}), // Provide the ticker service. As it is the main runner, it does not need to have a specific interface. 25 | ). 26 | InjectSlog(). // Enables automatic logger injection. 27 | InitTimeout(time.Second). // Set the timeout for the initialization phase. 28 | HealthCheckTimeout(time.Second). // Set the timeout for the health check phase. 29 | ShutdownTimeout(3 * time.Second) // Set the timeout for the shutdown phase. 30 | 31 | if err := p.Run(context.Background()); err != nil { // Run the application. 32 | panic(err) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /examples/web/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/zhulik/pal/examples/web/core" 9 | ) 10 | 11 | // Server is a simple http Server that calls Pinger.Ping on each request. 12 | type Server struct { 13 | Pinger core.Pinger 14 | Logger *slog.Logger 15 | 16 | server *http.Server 17 | } 18 | 19 | // Init initializes the server. 20 | func (s *Server) Init(_ context.Context) error { 21 | defer s.Logger.Info("Server initialized") 22 | 23 | s.server = &http.Server{ //nolint:gosec 24 | Addr: ":8080", 25 | } 26 | 27 | s.server.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 28 | if err := s.Pinger.Ping(r.Context()); err != nil { 29 | http.Error(w, err.Error(), http.StatusInternalServerError) 30 | return 31 | } 32 | w.WriteHeader(http.StatusOK) 33 | w.Write([]byte("pong")) //nolint:errcheck 34 | }) 35 | 36 | return nil 37 | } 38 | 39 | // Run runs the server. 40 | func (s *Server) Run(ctx context.Context) error { 41 | s.Logger.Info("Server running on :8080. Run `curl http://localhost:8080/` to see it in action.") 42 | 43 | // We don't use Shutdown here because ListenAndServe() does not natively support context. 44 | // instead we use a goroutine to listen for the context done signal and shutdown the server. 45 | go func() { 46 | <-ctx.Done() 47 | s.server.Shutdown(context.Background()) //nolint:errcheck 48 | }() 49 | 50 | if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { 51 | return err 52 | } 53 | 54 | return nil 55 | } 56 | -------------------------------------------------------------------------------- /service_factory0.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // ServiceFactory0 is a factory service that creates a new instance each time it is invoked. 8 | // It uses the provided function with no arguments to create the instance. 9 | type ServiceFactory0[I any, T any] struct { 10 | ServiceFactory[I, T] 11 | fn func(ctx context.Context) (T, error) 12 | } 13 | 14 | // Instance creates and returns a new instance of the service using the provided function. 15 | func (c *ServiceFactory0[I, T]) Instance(ctx context.Context, _ ...any) (any, error) { 16 | instance, err := c.fn(ctx) 17 | if err != nil { 18 | return nil, err 19 | } 20 | 21 | err = initService(ctx, c.Name(), instance, nil, c.P) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return instance, nil 27 | } 28 | 29 | // Factory returns a function that creates a new instance of the service. 30 | // The returned function has the signature func(ctx context.Context) (I, error). 31 | func (c *ServiceFactory0[I, T]) Factory() any { 32 | return func(ctx context.Context) (I, error) { 33 | instance, err := c.Instance(ctx) 34 | if err != nil { 35 | var i I 36 | return i, err 37 | } 38 | return instance.(I), nil 39 | } 40 | } 41 | 42 | // MustFactory returns a function that creates a new instance of the service. 43 | // The returned function has the signature func(ctx context.Context) I. 44 | // If the instance creation fails, it panics. 45 | func (c *ServiceFactory0[I, T]) MustFactory() any { 46 | return func(ctx context.Context) I { 47 | return must(c.Instance(ctx)).(I) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /examples/factories/ticker.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "time" 7 | 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | // ticker is a concrete implementation of the ticker interface. 12 | type ticker struct { 13 | Logger *slog.Logger // logger is injected by pal as is 14 | 15 | Pal *pal.Pal 16 | 17 | // CreatePinger is a factory function that creates a pinger service, it is injected by pal. 18 | CreatePinger func(ctx context.Context, url string) (Pinger, error) 19 | 20 | pinger Pinger 21 | ticker *time.Ticker // ticker is created in Init and stopped in Shutdown. 22 | } 23 | 24 | // Init initializes the ticker service. 25 | func (t *ticker) Init(ctx context.Context) error { //nolint:unparam 26 | defer t.Logger.Info("ticker initialized") 27 | 28 | pinger, err := t.CreatePinger(ctx, "https://google.com") 29 | if err != nil { 30 | return err 31 | } 32 | t.pinger = pinger 33 | 34 | t.ticker = time.NewTicker(time.Second) 35 | 36 | return nil 37 | } 38 | 39 | // Shutdown closes the ticker service. 40 | func (t *ticker) Shutdown(_ context.Context) error { //nolint:unparam 41 | t.Logger.Info("ticker shut down") 42 | 43 | t.ticker.Stop() 44 | 45 | return nil 46 | } 47 | 48 | // Run runs the ticker service, calls Pinger.Ping every second. 49 | func (t *ticker) Run(ctx context.Context) error { //nolint:unparam 50 | for { 51 | select { 52 | case <-ctx.Done(): 53 | return nil 54 | 55 | case <-t.ticker.C: 56 | if err := t.pinger.Ping(ctx); err != nil { 57 | t.Logger.Error("Failed to ping", "error", err) 58 | } 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /examples/factories/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/zhulik/pal" 8 | ) 9 | 10 | // Pinger is an interface for the pinger service. 11 | type Pinger interface { 12 | Ping(ctx context.Context) error 13 | } 14 | 15 | // main is the entry point of the program. 16 | func main() { 17 | // Create a new pal application, provide the services and initialize pal's lifecycle timeouts. 18 | // Pal is aware of the dependencies between the services and initlizes them in correct order: 19 | // first pinger, then ticker. After initialization, it runs the runners, in this case the ticker 20 | // When shutting down, it first shuts down the ticker, then the pinger. First it stops the runners, 21 | // then shuts down the services in the order reversed to the initialization. 22 | p := pal.New( 23 | pal.ProvideFactory1[Pinger](func(_ context.Context, url string) (*pinger, error) { 24 | return &pinger{URL: url}, nil 25 | }), // Provide the pinger service as the Pinger interface. 26 | pal.Provide(&ticker{}), // Provide the ticker service. As it is the main runner, it does not need to have a specific interface. 27 | ). 28 | InjectSlog(). // Enables automatic logger injection. 29 | InitTimeout(time.Second). // Set the timeout for the initialization phase. 30 | HealthCheckTimeout(time.Second). // Set the timeout for the health check phase. 31 | ShutdownTimeout(3 * time.Second) // Set the timeout for the shutdown phase. 32 | 33 | if err := p.Run(context.Background()); err != nil { // Run the application. 34 | panic(err) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /common_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | var ( 12 | errTest = errors.New("test error") 13 | errTest2 = errors.New("test error 2") 14 | ) 15 | 16 | type Pinger interface { 17 | Ping() 18 | } 19 | 20 | type Pinger1 struct{} 21 | 22 | func (p *Pinger1) Ping() {} 23 | 24 | type Pinger2 struct{} 25 | 26 | func (p *Pinger2) Ping() {} 27 | 28 | // TestServiceInterface is a simple interface for testing 29 | type TestServiceInterface any 30 | 31 | // TestServiceStruct is a test helper struct 32 | type TestServiceStruct struct { 33 | *MockHealthChecker 34 | *MockIniter 35 | *MockShutdowner 36 | } 37 | 38 | func NewMockTestServiceStruct(t *testing.T) *TestServiceStruct { 39 | return &TestServiceStruct{ 40 | MockHealthChecker: NewMockHealthChecker(t), 41 | MockIniter: NewMockIniter(t), 42 | MockShutdowner: NewMockShutdowner(t), 43 | } 44 | } 45 | 46 | // RunnerServiceStruct is a test helper struct 47 | type RunnerServiceStruct struct { 48 | *MockRunner 49 | *MockRunConfiger 50 | } 51 | 52 | func NewMockRunnerServiceStruct(t *testing.T) *RunnerServiceStruct { 53 | return &RunnerServiceStruct{ 54 | MockRunner: NewMockRunner(t), 55 | MockRunConfiger: NewMockRunConfiger(t), 56 | } 57 | } 58 | 59 | // DependentStruct is a struct with a dependency on TestServiceInterface 60 | type DependentStruct struct { 61 | Dependency *TestServiceStruct 62 | } 63 | 64 | func newPal(services ...pal.ServiceDef) *pal.Pal { 65 | return pal.New(services...). 66 | InitTimeout(time.Second). 67 | HealthCheckTimeout(time.Second). 68 | ShutdownTimeout(3 * time.Second) 69 | } 70 | -------------------------------------------------------------------------------- /healthcheck_server.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "time" 7 | ) 8 | 9 | type palHealthCheckServer interface { 10 | handle(w http.ResponseWriter, r *http.Request) 11 | } 12 | 13 | type healthCheckServer struct { 14 | Pal *Pal 15 | 16 | addr string 17 | path string 18 | 19 | server *http.Server 20 | } 21 | 22 | func (h *healthCheckServer) RunConfig() *RunConfig { 23 | return &RunConfig{ 24 | Wait: false, 25 | } 26 | } 27 | 28 | func (h *healthCheckServer) Init(_ context.Context) error { 29 | h.server = &http.Server{ 30 | Addr: h.addr, 31 | Handler: http.HandlerFunc(h.handle), 32 | ReadHeaderTimeout: time.Second, 33 | } 34 | 35 | return nil 36 | } 37 | 38 | func (h healthCheckServer) handle(w http.ResponseWriter, r *http.Request) { 39 | if r.Method != http.MethodGet { 40 | w.WriteHeader(http.StatusMethodNotAllowed) 41 | return 42 | } 43 | 44 | if r.URL.Path != h.path { 45 | w.WriteHeader(http.StatusNotFound) 46 | return 47 | } 48 | 49 | if err := h.Pal.HealthCheck(r.Context()); err != nil { 50 | w.WriteHeader(http.StatusInternalServerError) 51 | return 52 | } 53 | w.WriteHeader(http.StatusOK) 54 | } 55 | 56 | func (h *healthCheckServer) Run(ctx context.Context) error { 57 | go func() { 58 | <-ctx.Done() 59 | 60 | // create a new context as the one passed to Run is already canceled 61 | ctx, cancel := context.WithTimeout(context.Background(), h.Pal.Config().ShutdownTimeout) 62 | defer cancel() 63 | h.server.Shutdown(ctx) //nolint:errcheck 64 | }() 65 | 66 | err := h.server.ListenAndServe() 67 | 68 | if err != nil && err != http.ErrServerClosed { 69 | return err 70 | } 71 | 72 | return nil 73 | } 74 | -------------------------------------------------------------------------------- /inspect/static/tree.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pal Dependency Tree 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | 15 |
16 | 17 | 18 | 54 | 55 | -------------------------------------------------------------------------------- /service_factory1.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceFactory1 is a factory service that creates a new instance each time it is invoked. 9 | // It uses the provided function with one argument to create the instance. 10 | type ServiceFactory1[I any, T any, P1 any] struct { 11 | ServiceFactory[I, T] 12 | fn func(ctx context.Context, p1 P1) (T, error) 13 | } 14 | 15 | func (c *ServiceFactory1[I, T, P1]) Arguments() int { 16 | return 1 17 | } 18 | 19 | // Instance creates and returns a new instance of the service using the provided function. 20 | func (c *ServiceFactory1[I, T, P1]) Instance(ctx context.Context, args ...any) (any, error) { 21 | p1, ok := args[0].(P1) 22 | if !ok { 23 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[0], p1) 24 | } 25 | 26 | instance, err := c.fn(ctx, p1) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | err = initService(ctx, c.Name(), instance, nil, c.P) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | return instance, nil 37 | } 38 | 39 | // Factory returns a function that creates a new instance of the service. 40 | // The returned function has the signature func(ctx context.Context, p1 P1) (I, error). 41 | func (c *ServiceFactory1[I, T, P1]) Factory() any { 42 | return func(ctx context.Context, p1 P1) (I, error) { 43 | instance, err := c.Instance(ctx, p1) 44 | if err != nil { 45 | var i I 46 | return i, err 47 | } 48 | return instance.(I), nil 49 | } 50 | } 51 | 52 | // MustFactory returns a function that creates a new instance of the service. 53 | // The returned function has the signature func(ctx context.Context, p1 P1) I. 54 | // If the instance creation fails, it panics. 55 | func (c *ServiceFactory1[I, T, P1]) MustFactory() any { 56 | return func(ctx context.Context, p1 P1) I { 57 | return must(c.Instance(ctx, p1)).(I) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /example_pal_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | // ExampleService is a service that runs in the background 12 | type ExampleService interface { 13 | // This is a public API exposed to other components of the app. 14 | // No need to include pal interfaces here. 15 | Foo() string 16 | } 17 | 18 | // ExampleServiceImpl implements ExampleService and pal.Runner 19 | type ExampleServiceImpl struct { 20 | } 21 | 22 | // Init initializes the services 23 | func (s *ExampleServiceImpl) Init(_ context.Context) error { 24 | fmt.Println("init") 25 | 26 | // In a real app, here you'd create resources or open connections to other services and databases. 27 | 28 | return nil 29 | } 30 | 31 | // Run runs the background task 32 | func (s *ExampleServiceImpl) Run(_ context.Context) error { 33 | fmt.Println("run") 34 | 35 | // In a real application, this would do some work 36 | return nil 37 | } 38 | 39 | // Shutdown initializes the services 40 | func (s *ExampleServiceImpl) Shutdown(_ context.Context) error { 41 | fmt.Println("shutdown") 42 | 43 | // In a real app, here you'd release resources or close connections to other services and databases. 44 | 45 | return nil 46 | } 47 | 48 | // Foo does foo. 49 | func (s *ExampleServiceImpl) Foo() string { 50 | // In case of a regular service, you put your actual application logic here. 51 | // In case of a Runner - you may want to interact with the background job via channels, this is the place. 52 | return "foo" 53 | } 54 | 55 | // This example demonstrates how to use Pal with a runner service. 56 | func Example_pal_runner() { 57 | p := pal.New( 58 | pal.Provide[ExampleService](&ExampleServiceImpl{}), 59 | ). 60 | InitTimeout(time.Second). 61 | HealthCheckTimeout(time.Second). 62 | ShutdownTimeout(3 * time.Second) 63 | 64 | err := p.Run(context.Background()) 65 | if err != nil { 66 | fmt.Printf("Pal.Run returned error: %v\n", err) 67 | } 68 | 69 | // Output: 70 | // init 71 | // run 72 | // shutdown 73 | } 74 | -------------------------------------------------------------------------------- /service_factory2.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceFactory2 is a factory service that creates a new instance each time it is invoked. 9 | // It uses the provided function with two arguments to create the instance. 10 | type ServiceFactory2[I any, T any, P1 any, P2 any] struct { 11 | ServiceFactory[I, T] 12 | fn func(ctx context.Context, p1 P1, p2 P2) (T, error) 13 | } 14 | 15 | func (c *ServiceFactory2[I, T, P1, P2]) Arguments() int { 16 | return 2 17 | } 18 | 19 | // Instance creates and returns a new instance of the service using the provided function. 20 | func (c *ServiceFactory2[I, T, P1, P2]) Instance(ctx context.Context, args ...any) (any, error) { 21 | p1, ok := args[0].(P1) 22 | if !ok { 23 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[0], p1) 24 | } 25 | 26 | p2, ok := args[1].(P2) 27 | if !ok { 28 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[1], p2) 29 | } 30 | 31 | instance, err := c.fn(ctx, p1, p2) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | err = initService(ctx, c.Name(), instance, nil, c.P) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | return instance, nil 42 | } 43 | 44 | // Factory returns a function that creates a new instance of the service. 45 | // The returned function has the signature func(ctx context.Context) (I, error). 46 | func (c *ServiceFactory2[I, T, P1, P2]) Factory() any { 47 | return func(ctx context.Context, p1 P1, p2 P2) (I, error) { 48 | instance, err := c.Instance(ctx, p1, p2) 49 | if err != nil { 50 | var i I 51 | return i, err 52 | } 53 | return instance.(I), nil 54 | } 55 | } 56 | 57 | // MustFactory returns a function that creates a new instance of the service. 58 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2) I. 59 | // If the instance creation fails, it panics. 60 | func (c *ServiceFactory2[I, T, P1, P2]) MustFactory() any { 61 | return func(ctx context.Context, p1 P1, p2 P2) I { 62 | return must(c.Instance(ctx, p1, p2)).(I) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /runners.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "golang.org/x/sync/errgroup" 8 | ) 9 | 10 | var ErrNoMainRunners = errors.New("no main runners found") 11 | 12 | // RunServices runs the services in 2 runner groups: main and secondary. 13 | // Block until: 14 | // - passed context is canceled 15 | // - any of the runners fails 16 | // - all runners finish their work 17 | // It returns ErrNoMainRunners if no main runners among the services. 18 | // if any of the runners fail, the error is returned and and all other runners are stopped 19 | // by cancelling the context passed to them. 20 | func RunServices(ctx context.Context, services []ServiceDef) error { 21 | mainRunners, secondaryRunners := getRunners(services) 22 | 23 | if len(mainRunners) == 0 { 24 | return ErrNoMainRunners 25 | } 26 | 27 | ctx, cancel := context.WithCancel(ctx) 28 | defer cancel() 29 | 30 | runService := func(service ServiceDef) func() error { 31 | return func() error { 32 | err := service.Run(ctx) 33 | if errors.Is(err, context.Canceled) { 34 | return nil 35 | } 36 | return err 37 | } 38 | } 39 | 40 | awaitGroupContextAndCancelRoot := func(groupCtx context.Context) { 41 | go func() { 42 | // Errgoup cancels the context when any of the tasks it manages returns an error. 43 | // We want to stop if any of the runners fails no matter main or secondary and propagate 44 | // the cause to the main context. 45 | <-groupCtx.Done() 46 | 47 | cancel() 48 | }() 49 | } 50 | 51 | main, mainCtx := errgroup.WithContext(ctx) 52 | go awaitGroupContextAndCancelRoot(mainCtx) 53 | for _, service := range mainRunners { 54 | main.Go(runService(service)) 55 | } 56 | 57 | var secondary *errgroup.Group 58 | if len(secondaryRunners) > 0 { 59 | var secondaryCtx context.Context 60 | secondary, secondaryCtx = errgroup.WithContext(ctx) 61 | go awaitGroupContextAndCancelRoot(secondaryCtx) 62 | } 63 | 64 | for _, service := range secondaryRunners { 65 | secondary.Go(runService(service)) 66 | } 67 | 68 | // block until all main and secondary(if any) runners finish 69 | err := main.Wait() 70 | if secondary != nil { 71 | err = errors.Join(err, secondary.Wait()) 72 | } 73 | 74 | return err 75 | } 76 | -------------------------------------------------------------------------------- /service_factory3.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceFactory3 is a factory service that creates a new instance each time it is invoked. 9 | // It uses the provided function with three arguments to create the instance. 10 | type ServiceFactory3[I any, T any, P1 any, P2 any, P3 any] struct { 11 | ServiceFactory[I, T] 12 | fn func(ctx context.Context, p1 P1, p2 P2, p3 P3) (T, error) 13 | } 14 | 15 | func (c *ServiceFactory3[I, T, P1, P2, P3]) Arguments() int { 16 | return 3 17 | } 18 | 19 | // Instance creates and returns a new instance of the service using the provided function. 20 | func (c *ServiceFactory3[I, T, P1, P2, P3]) Instance(ctx context.Context, args ...any) (any, error) { 21 | p1, ok := args[0].(P1) 22 | if !ok { 23 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[0], p1) 24 | } 25 | 26 | p2, ok := args[1].(P2) 27 | if !ok { 28 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[1], p2) 29 | } 30 | 31 | p3, ok := args[2].(P3) 32 | if !ok { 33 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[2], p3) 34 | } 35 | 36 | instance, err := c.fn(ctx, p1, p2, p3) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | err = initService(ctx, c.Name(), instance, nil, c.P) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | return instance, nil 47 | } 48 | 49 | // Factory returns a function that creates a new instance of the service. 50 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3) (I, error). 51 | func (c *ServiceFactory3[I, T, P1, P2, P3]) Factory() any { 52 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3) (I, error) { 53 | instance, err := c.Instance(ctx, p1, p2, p3) 54 | if err != nil { 55 | var i I 56 | return i, err 57 | } 58 | return instance.(I), nil 59 | } 60 | } 61 | 62 | // MustFactory returns a function that creates a new instance of the service. 63 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3) I. 64 | // If the instance creation fails, it panics. 65 | func (c *ServiceFactory3[I, T, P1, P2, P3]) MustFactory() any { 66 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3) I { 67 | return must(c.Instance(ctx, p1, p2, p3)).(I) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /inspect/tree_json.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "encoding/json" 5 | "strings" 6 | 7 | "github.com/zhulik/pal" 8 | "github.com/zhulik/pal/pkg/dag" 9 | ) 10 | 11 | type DAGJSON struct { 12 | Nodes []NodeJSON `json:"nodes"` 13 | Edges []EdgeJSON `json:"edges"` 14 | } 15 | 16 | type NodeJSON struct { 17 | ID string `json:"id"` 18 | Label string `json:"label"` 19 | InDegree int `json:"inDegree"` 20 | OutDegree int `json:"outDegree"` 21 | 22 | Initer bool `json:"initer"` 23 | Runner bool `json:"runner"` 24 | HealthChecker bool `json:"healthChecker"` 25 | Shutdowner bool `json:"shutdowner"` 26 | } 27 | 28 | type EdgeJSON struct { 29 | From string `json:"from"` 30 | To string `json:"to"` 31 | } 32 | 33 | func serviceToJSON(id string, inDegree int, outDegree int, service pal.ServiceDef) NodeJSON { 34 | var initer, runner, healthChecker, shutdowner bool 35 | 36 | if _, ok := service.Make().(pal.Initer); ok { 37 | initer = true 38 | } 39 | 40 | if _, ok := service.Make().(pal.Runner); ok { 41 | runner = true 42 | } 43 | 44 | if _, ok := service.Make().(pal.HealthChecker); ok { 45 | healthChecker = true 46 | } 47 | 48 | if _, ok := service.Make().(pal.Shutdowner); ok { 49 | shutdowner = true 50 | } 51 | 52 | idParts := strings.Split(id, "/") 53 | label := idParts[len(idParts)-1] 54 | 55 | if strings.HasPrefix(id, "*") { 56 | label = "*" + label 57 | } 58 | 59 | return NodeJSON{ 60 | ID: id, 61 | Label: label, 62 | InDegree: inDegree, 63 | OutDegree: outDegree, 64 | 65 | Initer: initer, 66 | Runner: runner, 67 | HealthChecker: healthChecker, 68 | Shutdowner: shutdowner, 69 | } 70 | } 71 | 72 | func DAGToJSON(d *dag.DAG[string, pal.ServiceDef]) ([]byte, error) { 73 | var nodes []NodeJSON 74 | var edges []EdgeJSON 75 | 76 | // Convert all vertices to NodeJSON 77 | for id, service := range d.Vertices() { 78 | nodes = append(nodes, serviceToJSON(id, d.GetInDegree(id), len(d.Edges()[id]), service)) 79 | } 80 | 81 | // Convert all edges to EdgeJSON 82 | for from, targets := range d.Edges() { 83 | for to := range targets { 84 | edges = append(edges, EdgeJSON{ 85 | From: from, 86 | To: to, 87 | }) 88 | } 89 | } 90 | 91 | dagJSON := DAGJSON{ 92 | Nodes: nodes, 93 | Edges: edges, 94 | } 95 | 96 | return json.Marshal(dagJSON) 97 | } 98 | -------------------------------------------------------------------------------- /errors.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | // Error variables used throughout the package 8 | var ( 9 | // ErrServiceNotFound is returned when a requested service is not found in the container. 10 | // This typically happens when trying to Invoke a service that hasn't been registered. 11 | ErrServiceNotFound = errors.New("service not found") 12 | 13 | // ErrMultipleServicesFoundByInterface is returned when multiple services are found in the container by interface. 14 | ErrMultipleServicesFoundByInterface = errors.New("multiple services found by interface") 15 | 16 | // ErrServiceInitFailed is returned when a service fails to initialize. 17 | // This can happen during container initialization if a service's Init method returns an error. 18 | ErrServiceInitFailed = errors.New("service initialization failed") 19 | 20 | // ErrServiceInvalid is returned when a service is invalid. 21 | // This can happen when a service doesn't implement a required interface or when type assertions fail. 22 | ErrServiceInvalid = errors.New("service invalid") 23 | 24 | // ErrServiceInvalidArgumentsCount is returned when a service is called with incorrect number of arguments. 25 | ErrServiceInvalidArgumentsCount = errors.New("service called with incorrect number of arguments") 26 | 27 | // ErrServiceInvalidArgumentType is returned when a service is called with incorrect argument type. 28 | ErrServiceInvalidArgumentType = errors.New("service called with incorrect argument type") 29 | 30 | // ErrFactoryServiceDependency is returned when a service with a factory service dependency is invoked. 31 | ErrFactoryServiceDependency = errors.New("factory service cannot be a dependency of another service") 32 | 33 | // ErrServiceInvalidCast is returned when a service is cast to a different type. 34 | ErrServiceInvalidCast = errors.New("failed to cast service to the expected type") 35 | 36 | // ErrInvokerIsNotInContext is returned when a context passed to Invoke does not contain a Pal instance. 37 | ErrInvokerIsNotInContext = errors.New("invoker is not in context") 38 | 39 | // ErrInvalidTag is returned when a tag is invalid. 40 | ErrInvalidTag = errors.New("invalid tag") 41 | 42 | // ErrNotAnInterface is returned when a type is not an interface. 43 | ErrNotAnInterface = errors.New("not an interface") 44 | ) 45 | 46 | type PanicError struct { 47 | error 48 | backtrace string 49 | } 50 | 51 | func (e *PanicError) Backtrace() string { 52 | return e.backtrace 53 | } 54 | -------------------------------------------------------------------------------- /service_factory4.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceFactory4 is a factory service that creates a new instance each time it is invoked. 9 | // It uses the provided function with four arguments to create the instance. 10 | type ServiceFactory4[I any, T any, P1 any, P2 any, P3 any, P4 any] struct { 11 | ServiceFactory[I, T] 12 | fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) (T, error) 13 | } 14 | 15 | func (c *ServiceFactory4[I, T, P1, P2, P3, P4]) Arguments() int { 16 | return 4 17 | } 18 | 19 | // Instance creates and returns a new instance of the service using the provided function. 20 | func (c *ServiceFactory4[I, T, P1, P2, P3, P4]) Instance(ctx context.Context, args ...any) (any, error) { 21 | p1, ok := args[0].(P1) 22 | if !ok { 23 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[0], p1) 24 | } 25 | 26 | p2, ok := args[1].(P2) 27 | if !ok { 28 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[1], p2) 29 | } 30 | 31 | p3, ok := args[2].(P3) 32 | if !ok { 33 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[2], p3) 34 | } 35 | 36 | p4, ok := args[3].(P4) 37 | if !ok { 38 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[3], p4) 39 | } 40 | 41 | instance, err := c.fn(ctx, p1, p2, p3, p4) 42 | if err != nil { 43 | return nil, err 44 | } 45 | 46 | err = initService(ctx, c.Name(), instance, nil, c.P) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | return instance, nil 52 | } 53 | 54 | // Factory returns a function that creates a new instance of the service. 55 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) (I, error). 56 | func (c *ServiceFactory4[I, T, P1, P2, P3, P4]) Factory() any { 57 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) (I, error) { 58 | instance, err := c.Instance(ctx, p1, p2, p3, p4) 59 | if err != nil { 60 | var i I 61 | return i, err 62 | } 63 | return instance.(I), nil 64 | } 65 | } 66 | 67 | // MustFactory returns a function that creates a new instance of the service. 68 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) I. 69 | // If the instance creation fails, it panics. 70 | func (c *ServiceFactory4[I, T, P1, P2, P3, P4]) MustFactory() any { 71 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) I { 72 | return must(c.Instance(ctx, p1, p2, p3, p4)).(I) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /inspect/inspect.go: -------------------------------------------------------------------------------- 1 | package inspect 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/zhulik/pal" 12 | ) 13 | 14 | const ( 15 | jsonContentType = "application/json" 16 | htmlContentType = "text/html" 17 | ) 18 | 19 | type Inspect struct { 20 | P *pal.Pal 21 | 22 | port int 23 | 24 | server *http.Server 25 | } 26 | 27 | func (i *Inspect) Init(_ context.Context) error { 28 | i.server = &http.Server{ 29 | Addr: fmt.Sprintf(":%d", i.port), 30 | ReadHeaderTimeout: time.Second, 31 | WriteTimeout: time.Second, 32 | ReadTimeout: time.Second, 33 | IdleTimeout: time.Second, 34 | } 35 | 36 | mux := http.NewServeMux() 37 | 38 | mux.HandleFunc("/pal/tree.json", i.httpTreeJSON) 39 | mux.HandleFunc("/pal/tree", i.httpTree) 40 | 41 | staticServer := http.FileServerFS(StaticFS) 42 | 43 | mux.Handle("/pal/", http.StripPrefix("/pal/", staticServer)) 44 | 45 | i.server.Handler = mux 46 | 47 | return nil 48 | } 49 | 50 | func (i *Inspect) RunConfig() *pal.RunConfig { 51 | return &pal.RunConfig{ 52 | Wait: false, 53 | } 54 | } 55 | 56 | func (i *Inspect) Run(ctx context.Context) error { 57 | ln, err := net.Listen("tcp", i.server.Addr) 58 | if err != nil { 59 | return err 60 | } 61 | i.P.Logger().Info("Starting Inspect HTTP server", "address", i.server.Addr) 62 | 63 | go func() { 64 | <-ctx.Done() 65 | 66 | // create a new context as the one passed to Run is already canceled 67 | ctx, cancel := context.WithTimeout(context.Background(), i.P.Config().ShutdownTimeout) 68 | defer cancel() 69 | 70 | i.server.Shutdown(ctx) //nolint:errcheck 71 | }() 72 | 73 | err = i.server.Serve(ln) 74 | if !errors.Is(err, http.ErrServerClosed) { 75 | return err 76 | } 77 | return nil 78 | } 79 | 80 | func (i *Inspect) httpTreeJSON(w http.ResponseWriter, _ *http.Request) { 81 | w.Header().Set("Content-Type", jsonContentType) 82 | json, err := DAGToJSON(i.P.Container().Graph()) 83 | if err != nil { 84 | w.WriteHeader(http.StatusInternalServerError) 85 | return 86 | } 87 | 88 | _, err = w.Write(json) 89 | if err != nil { 90 | panic(err) 91 | } 92 | } 93 | 94 | func (i *Inspect) httpTree(w http.ResponseWriter, _ *http.Request) { 95 | w.Header().Set("Content-Type", htmlContentType) 96 | 97 | treeHTML, err := StaticFS.ReadFile("static/tree.html") 98 | if err != nil { 99 | w.WriteHeader(http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | _, err = w.Write(treeHTML) 104 | if err != nil { 105 | panic(err) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /service_fn_singleton.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // ServiceFnSingleton is a singleton service that is created using a function. 8 | // It is created during initialization and reused for the lifetime of the application. 9 | type ServiceFnSingleton[I, T any] struct { 10 | ServiceFactory[I, T] 11 | fn func(ctx context.Context) (T, error) 12 | 13 | hooks LifecycleHooks[T] 14 | 15 | instance T 16 | } 17 | 18 | func (c *ServiceFnSingleton[I, T]) RunConfig() *RunConfig { 19 | configer, ok := any(c.instance).(RunConfiger) 20 | if ok { 21 | return configer.RunConfig() 22 | } 23 | 24 | if _, ok := c.Make().(Runner); ok { 25 | return defaultRunConfig 26 | } 27 | 28 | return nil 29 | } 30 | 31 | // Run executes the service if it implements the Runner interface. 32 | func (c *ServiceFnSingleton[I, T]) Run(ctx context.Context) error { 33 | return runService(ctx, c.Name(), c.instance, c.P) 34 | } 35 | 36 | // Init initializes the service by calling the provided function to create the instance. 37 | func (c *ServiceFnSingleton[I, T]) Init(ctx context.Context) error { 38 | instance, err := c.fn(ctx) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | if initer, ok := any(instance).(Initer); ok { 44 | if err := initer.Init(ctx); err != nil { 45 | return err 46 | } 47 | } 48 | 49 | c.instance = instance 50 | 51 | return nil 52 | } 53 | 54 | // HealthCheck performs a health check on the service if it implements the HealthChecker interface. 55 | func (c *ServiceFnSingleton[I, T]) HealthCheck(ctx context.Context) error { 56 | return healthcheckService(ctx, c.Name(), c.instance, c.hooks.HealthCheck, c.P) 57 | } 58 | 59 | // Shutdown gracefully shuts down the service if it implements the Shutdowner interface. 60 | func (c *ServiceFnSingleton[I, T]) Shutdown(ctx context.Context) error { 61 | return shutdownService(ctx, c.Name(), c.instance, c.hooks.Shutdown, c.P) 62 | } 63 | 64 | // Instance returns the singleton instance of the service. 65 | func (c *ServiceFnSingleton[I, T]) Instance(_ context.Context, _ ...any) (any, error) { 66 | return c.instance, nil 67 | } 68 | 69 | func (c *ServiceFnSingleton[I, T]) ToShutdown(hook LifecycleHook[T]) *ServiceFnSingleton[I, T] { 70 | c.hooks.Shutdown = hook 71 | return c 72 | } 73 | 74 | // ToHealthCheck registers a hook function that will be called to perform a health check on the service. 75 | // If the service implements the HealthChecker interface, the HealthCheck() method is not called, 76 | // the hook has higher priority. 77 | func (c *ServiceFnSingleton[I, T]) ToHealthCheck(hook LifecycleHook[T]) *ServiceFnSingleton[I, T] { 78 | c.hooks.HealthCheck = hook 79 | return c 80 | } 81 | -------------------------------------------------------------------------------- /service_factory5.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | // ServiceFactory5 is a factory service that creates a new instance each time it is invoked. 9 | // It uses the provided function with five arguments to create the instance. 10 | type ServiceFactory5[I any, T any, P1 any, P2 any, P3 any, P4 any, P5 any] struct { 11 | ServiceFactory[I, T] 12 | fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) (T, error) 13 | } 14 | 15 | func (c *ServiceFactory5[I, T, P1, P2, P3, P4, P5]) Arguments() int { 16 | return 5 17 | } 18 | 19 | // Instance creates and returns a new instance of the service using the provided function. 20 | func (c *ServiceFactory5[I, T, P1, P2, P3, P4, P5]) Instance(ctx context.Context, args ...any) (any, error) { 21 | p1, ok := args[0].(P1) 22 | if !ok { 23 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[0], p1) 24 | } 25 | 26 | p2, ok := args[1].(P2) 27 | if !ok { 28 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[1], p2) 29 | } 30 | 31 | p3, ok := args[2].(P3) 32 | if !ok { 33 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[2], p3) 34 | } 35 | 36 | p4, ok := args[3].(P4) 37 | if !ok { 38 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[3], p4) 39 | } 40 | 41 | p5, ok := args[4].(P5) 42 | if !ok { 43 | return nil, fmt.Errorf("%w: %T, expected %T", ErrServiceInvalidArgumentType, args[4], p5) 44 | } 45 | 46 | instance, err := c.fn(ctx, p1, p2, p3, p4, p5) 47 | if err != nil { 48 | return nil, err 49 | } 50 | 51 | err = initService(ctx, c.Name(), instance, nil, c.P) 52 | if err != nil { 53 | return nil, err 54 | } 55 | 56 | return instance, nil 57 | } 58 | 59 | // Factory returns a function that creates a new instance of the service. 60 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) (I, error). 61 | func (c *ServiceFactory5[I, T, P1, P2, P3, P4, P5]) Factory() any { 62 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) (I, error) { 63 | instance, err := c.Instance(ctx, p1, p2, p3, p4, p5) 64 | if err != nil { 65 | var i I 66 | return i, err 67 | } 68 | return instance.(I), nil 69 | } 70 | } 71 | 72 | // MustFactory returns a function that creates a new instance of the service. 73 | // The returned function has the signature func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) I. 74 | // If the instance creation fails, it panics. 75 | func (c *ServiceFactory5[I, T, P1, P2, P3, P4, P5]) MustFactory() any { 76 | return func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) I { 77 | return must(c.Instance(ctx, p1, p2, p3, p4, p5)).(I) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /interfaces.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "reflect" 6 | ) 7 | 8 | // RunConfiger is an optional interface that can be implemented by a runner to tell Pal how to handle it. 9 | type RunConfiger interface { 10 | RunConfig() *RunConfig 11 | } 12 | 13 | // ServiceDef is a definition of a service. In the case of a singleton service, it also holds the instance. 14 | // This interface combines all the lifecycle interfaces (Initer, HealthChecker, Shutdowner, Runner) 15 | // and adds methods specific to service definition and management. 16 | type ServiceDef interface { 17 | Initer 18 | HealthChecker 19 | Shutdowner 20 | Runner 21 | RunConfiger 22 | 23 | // Name returns a name of the service, this will be used to identify the service in the container. 24 | // The name is typically derived from the interface type the service implements. 25 | Name() string 26 | 27 | // Make only creates a new instance of the service, it doesn't initialize it. 28 | // Used only to build the dependency DAG by analyzing the fields of the returned instance. 29 | // This method should not have side effects as it may be called multiple times. 30 | Make() any 31 | 32 | // Instance returns a stored instance in the case of singleton service and a new instance in the case of factory. 33 | // For singletons, this returns the cached instance after initialization. 34 | // For factories, this creates and returns a new instance each time. 35 | Instance(ctx context.Context, args ...any) (any, error) 36 | 37 | // Arguments returns the number of arguments the service expects. 38 | // This is used to validate the number of arguments passed to the service. 39 | Arguments() int 40 | 41 | // Dependencies allows services to provide their own dependencies. 42 | Dependencies() []ServiceDef 43 | } 44 | 45 | // Invoker is an interface for retrieving services from a container and injecting them into structs. 46 | // Both Container and Pal implement this interface, allowing services to be retrieved from either. 47 | type Invoker interface { 48 | // Invoke retrieves a service by name from the container. 49 | // Returns the service instance or an error if the service is not found or cannot be initialized. 50 | Invoke(ctx context.Context, name string, args ...any) (any, error) 51 | 52 | // InvokeByInterface retrieves a service by interface from the container. 53 | // Returns the service instance or an error if the service is not found or cannot be initialized. 54 | InvokeByInterface(ctx context.Context, iface reflect.Type, args ...any) (any, error) 55 | 56 | // InjectInto injects services into the fields of the target struct. 57 | // It looks at each field's type and tries to find a matching service in the container. 58 | // Only exported fields can be injected into. 59 | InjectInto(ctx context.Context, target any) error 60 | } 61 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zhulik/pal 2 | 3 | go 1.24.1 4 | 5 | toolchain go1.24.2 6 | 7 | require ( 8 | github.com/go-playground/validator/v10 v10.26.0 9 | github.com/samber/go-type-to-string v1.8.0 10 | github.com/stretchr/testify v1.10.0 11 | golang.org/x/sync v0.16.0 12 | ) 13 | 14 | require ( 15 | github.com/brunoga/deep v1.2.4 // indirect 16 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 17 | github.com/fatih/structs v1.1.0 // indirect 18 | github.com/fsnotify/fsnotify v1.8.0 // indirect 19 | github.com/gabriel-vasile/mimetype v1.4.8 // indirect 20 | github.com/go-playground/locales v0.14.1 // indirect 21 | github.com/go-playground/universal-translator v0.18.1 // indirect 22 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect 23 | github.com/google/licensecheck v0.3.1 // indirect 24 | github.com/google/safehtml v0.0.3-0.20211026203422-d6f0e11a5516 // indirect 25 | github.com/huandu/xstrings v1.5.0 // indirect 26 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 27 | github.com/jedib0t/go-pretty/v6 v6.6.7 // indirect 28 | github.com/knadh/koanf/maps v0.1.2 // indirect 29 | github.com/knadh/koanf/parsers/yaml v0.1.0 // indirect 30 | github.com/knadh/koanf/providers/env v1.0.0 // indirect 31 | github.com/knadh/koanf/providers/file v1.1.2 // indirect 32 | github.com/knadh/koanf/providers/posflag v0.1.0 // indirect 33 | github.com/knadh/koanf/providers/structs v0.1.0 // indirect 34 | github.com/knadh/koanf/v2 v2.2.1 // indirect 35 | github.com/leodido/go-urn v1.4.0 // indirect 36 | github.com/mattn/go-colorable v0.1.13 // indirect 37 | github.com/mattn/go-isatty v0.0.20 // indirect 38 | github.com/mattn/go-runewidth v0.0.16 // indirect 39 | github.com/mitchellh/copystructure v1.2.0 // indirect 40 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 41 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 42 | github.com/rivo/uniseg v0.4.7 // indirect 43 | github.com/rs/zerolog v1.33.0 // indirect 44 | github.com/spf13/cobra v1.8.1 // indirect 45 | github.com/spf13/pflag v1.0.5 // indirect 46 | github.com/stretchr/objx v0.5.2 // indirect 47 | github.com/vektra/mockery/v3 v3.5.4 // indirect 48 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect 49 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect 50 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect 51 | golang.org/x/crypto v0.41.0 // indirect 52 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect 53 | golang.org/x/mod v0.27.0 // indirect 54 | golang.org/x/net v0.43.0 // indirect 55 | golang.org/x/pkgsite v0.0.0-20250424231009-e863a039941f // indirect 56 | golang.org/x/sys v0.35.0 // indirect 57 | golang.org/x/term v0.34.0 // indirect 58 | golang.org/x/text v0.28.0 // indirect 59 | golang.org/x/tools v0.36.0 // indirect 60 | gopkg.in/yaml.v3 v3.0.1 // indirect 61 | rsc.io/markdown v0.0.0-20231214224604-88bb533a6020 // indirect 62 | ) 63 | 64 | tool ( 65 | github.com/vektra/mockery/v3 66 | golang.org/x/pkgsite/cmd/pkgsite 67 | ) 68 | -------------------------------------------------------------------------------- /service_const_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/mock" 9 | "github.com/zhulik/pal" 10 | ) 11 | 12 | // TestService_Name tests the Name method of the service struct 13 | func TestService_Name(t *testing.T) { 14 | t.Parallel() 15 | 16 | t.Run("returns correct name for interface type", func(t *testing.T) { 17 | t.Parallel() 18 | 19 | service := pal.Provide(NewMockTestServiceStruct(t)) 20 | 21 | assert.Equal(t, "*github.com/zhulik/pal_test.TestServiceStruct", service.Name()) 22 | }) 23 | } 24 | 25 | // TestService_Instance tests the Instance method of the service struct 26 | func TestService_Instance(t *testing.T) { 27 | t.Parallel() 28 | 29 | t.Run("returns instance for singleton service", func(t *testing.T) { 30 | t.Parallel() 31 | 32 | service := pal.Provide(NewMockRunnerServiceStruct(t)) 33 | p := newPal(service) 34 | 35 | ctx := pal.WithPal(t.Context(), p) 36 | 37 | err := p.Init(t.Context()) 38 | assert.NoError(t, err) 39 | 40 | instance1, err := service.Instance(ctx) 41 | 42 | assert.NoError(t, err) 43 | assert.NotNil(t, instance1) 44 | 45 | instance2, err := service.Instance(ctx) 46 | 47 | assert.NoError(t, err) 48 | assert.NotNil(t, instance1) 49 | assert.Same(t, instance1, instance2) 50 | }) 51 | } 52 | 53 | // TestService_ToInit tests the ToInit hook functionality 54 | func TestService_ToInit(t *testing.T) { 55 | t.Parallel() 56 | 57 | t.Run("hook is called when set", func(t *testing.T) { 58 | t.Parallel() 59 | 60 | var hookCalled bool 61 | service := pal.Provide(NewMockTestServiceStruct(t)). 62 | ToInit(func(_ context.Context, _ *TestServiceStruct, _ *pal.Pal) error { 63 | hookCalled = true 64 | return nil 65 | }) 66 | p := newPal(service) 67 | 68 | ctx := pal.WithPal(t.Context(), p) 69 | 70 | err := p.Init(t.Context()) 71 | assert.NoError(t, err) 72 | 73 | instance, err := service.Instance(ctx) 74 | assert.NoError(t, err) 75 | assert.NotNil(t, instance) 76 | 77 | assert.True(t, hookCalled) 78 | }) 79 | 80 | t.Run("no error when hook is not set", func(t *testing.T) { 81 | t.Parallel() 82 | 83 | s := NewMockTestServiceStruct(t) 84 | s.MockIniter.EXPECT().Init(mock.Anything).Return(nil) 85 | 86 | service := pal.Provide[any](s) 87 | p := newPal(service) 88 | 89 | ctx := pal.WithPal(t.Context(), p) 90 | 91 | err := p.Init(t.Context()) 92 | assert.NoError(t, err) 93 | 94 | instance, err := service.Instance(ctx) 95 | assert.NoError(t, err) 96 | assert.NotNil(t, instance) 97 | }) 98 | 99 | t.Run("propagates error from hook", func(t *testing.T) { 100 | t.Parallel() 101 | 102 | service := pal.Provide(NewMockTestServiceStruct(t)). 103 | ToInit(func(_ context.Context, _ *TestServiceStruct, _ *pal.Pal) error { 104 | return errTest 105 | }) 106 | p := newPal(service) 107 | 108 | // The error should be propagated from the hook through Initialize to Init 109 | err := p.Init(t.Context()) 110 | assert.ErrorIs(t, err, errTest) 111 | }) 112 | } 113 | -------------------------------------------------------------------------------- /service_const.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // ServiceConst is a service that wraps a constant value. 8 | // It is used to register existing instances as services. 9 | type ServiceConst[T any] struct { 10 | ServiceTyped[T] 11 | 12 | hooks LifecycleHooks[T] 13 | 14 | instance T 15 | } 16 | 17 | func (c *ServiceConst[T]) RunConfig() *RunConfig { 18 | configer, ok := any(c.instance).(RunConfiger) 19 | if ok { 20 | return configer.RunConfig() 21 | } 22 | 23 | if _, ok := any(c.instance).(Runner); ok { 24 | return defaultRunConfig 25 | } 26 | 27 | return nil 28 | } 29 | 30 | // Run executes the service if it implements the Runner interface. 31 | func (c *ServiceConst[T]) Run(ctx context.Context) error { 32 | return runService(ctx, c.Name(), c.instance, c.P) 33 | } 34 | 35 | // Init is a no-op for const services as they are already initialized. 36 | // It injects dependencies to the stored instance and calls its Init method if it implements Initer. 37 | func (c *ServiceConst[T]) Init(ctx context.Context) error { 38 | return initService(ctx, c.Name(), c.instance, c.hooks.Init, c.P) 39 | } 40 | 41 | // Make is a no-op for factory services as they are created on demand. 42 | func (c *ServiceConst[T]) Make() any { 43 | return c.instance 44 | } 45 | 46 | // HealthCheck performs a health check on the service if it implements the HealthChecker interface. 47 | func (c *ServiceConst[T]) HealthCheck(ctx context.Context) error { 48 | return healthcheckService(ctx, c.Name(), c.instance, c.hooks.HealthCheck, c.P) 49 | } 50 | 51 | // Shutdown gracefully shuts down the service if it implements the Shutdowner interface. 52 | func (c *ServiceConst[T]) Shutdown(ctx context.Context) error { 53 | return shutdownService(ctx, c.Name(), c.instance, c.hooks.Shutdown, c.P) 54 | } 55 | 56 | // Instance returns the constant instance of the service. 57 | func (c *ServiceConst[T]) Instance(_ context.Context, _ ...any) (any, error) { 58 | return c.instance, nil 59 | } 60 | 61 | // ToInit registers a hook function that will be called to initialize the service. 62 | // This hook is called after the service is injected with its dependencies. 63 | // If the service implements the Initer interface, the Init() method is not called, 64 | // the hook has higher priority. 65 | func (c *ServiceConst[T]) ToInit(hook LifecycleHook[T]) *ServiceConst[T] { 66 | c.hooks.Init = hook 67 | return c 68 | } 69 | 70 | // ToShutdown registers a hook function that will be called to shutdown the service. 71 | // This hook is called before service's dependencies are shutdown. 72 | // If the service implements the Shutdowner interface, the Shutdown() method is not called, 73 | // the hook has higher priority. 74 | func (c *ServiceConst[T]) ToShutdown(hook LifecycleHook[T]) *ServiceConst[T] { 75 | c.hooks.Shutdown = hook 76 | return c 77 | } 78 | 79 | // ToHealthCheck registers a hook function that will be called to perform a health check on the service. 80 | // If the service implements the HealthChecker interface, the HealthCheck() method is not called, 81 | // the hook has higher priority. 82 | func (c *ServiceConst[T]) ToHealthCheck(hook LifecycleHook[T]) *ServiceConst[T] { 83 | c.hooks.HealthCheck = hook 84 | return c 85 | } 86 | -------------------------------------------------------------------------------- /services.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | ) 7 | 8 | func runService(ctx context.Context, name string, instance any, p *Pal) error { 9 | logger := p.logger.With("service", name) 10 | runner, ok := instance.(Runner) 11 | if !ok { 12 | return nil 13 | } 14 | 15 | err := tryWrap(func() error { 16 | logger.Debug("Running") 17 | err := runner.Run(ctx) 18 | if err != nil { 19 | logger.Error("Runner exited with error", "error", err) 20 | return err 21 | } 22 | 23 | logger.Debug("Runner finished successfully") 24 | return nil 25 | })() 26 | 27 | if err != nil { 28 | if panicErr, ok := err.(*PanicError); ok { 29 | fmt.Printf("panic: %s\n%s\n", panicErr.Error(), panicErr.Backtrace()) 30 | } 31 | } 32 | 33 | return err 34 | } 35 | 36 | func healthcheckService[T any](ctx context.Context, name string, instance T, hook LifecycleHook[T], p *Pal) error { 37 | logger := p.logger.With("service", name) 38 | if hook != nil { 39 | logger.Debug("Calling ToHealthCheck hook") 40 | err := hook(ctx, instance, p) 41 | if err != nil { 42 | logger.Error("Healthcheck hook failed", "error", err) 43 | } 44 | return err 45 | } 46 | 47 | h, ok := any(instance).(HealthChecker) 48 | if !ok { 49 | return nil 50 | } 51 | 52 | err := h.HealthCheck(ctx) 53 | if err != nil { 54 | logger.Error("Healthcheck failed", "error", err) 55 | return err 56 | } 57 | 58 | return nil 59 | } 60 | 61 | func shutdownService[T any](ctx context.Context, name string, instance T, hook LifecycleHook[T], p *Pal) error { 62 | logger := p.logger.With("service", name) 63 | if hook != nil { 64 | logger.Debug("Calling ToShutdown hook") 65 | err := hook(ctx, instance, p) 66 | if err != nil { 67 | logger.Error("Shutdown hook failed", "error", err) 68 | } 69 | return err 70 | } 71 | 72 | h, ok := any(instance).(Shutdowner) 73 | if !ok { 74 | return nil 75 | } 76 | 77 | err := h.Shutdown(ctx) 78 | if err != nil { 79 | logger.Error("Shutdown failed", "error", err) 80 | return err 81 | } 82 | 83 | return nil 84 | } 85 | 86 | func initService[T any](ctx context.Context, name string, instance T, hook LifecycleHook[T], p *Pal) error { 87 | logger := p.logger.With("service", name) 88 | 89 | err := p.InjectInto(ctx, instance) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | if hook != nil { 95 | logger.Debug("Calling ToInit hook") 96 | err := hook(ctx, instance, p) 97 | if err != nil { 98 | logger.Error("Init hook failed", "error", err) 99 | } 100 | return err 101 | } 102 | 103 | if initer, ok := any(instance).(Initer); ok && any(instance) != any(p) { 104 | logger.Debug("Calling Init method") 105 | if err := initer.Init(ctx); err != nil { 106 | logger.Error("Init failed", "error", err) 107 | return err 108 | } 109 | } 110 | return nil 111 | } 112 | 113 | func flattenServices(services []ServiceDef) []ServiceDef { 114 | seen := make(map[ServiceDef]bool) 115 | var result []ServiceDef 116 | 117 | var process func([]ServiceDef) 118 | process = func(svcs []ServiceDef) { 119 | for _, svc := range svcs { 120 | if _, ok := seen[svc]; !ok { 121 | seen[svc] = true 122 | 123 | if _, ok := svc.(*ServiceList); !ok { 124 | result = append(result, svc) 125 | } 126 | 127 | process(svc.Dependencies()) 128 | } 129 | } 130 | } 131 | 132 | process(services) 133 | return result 134 | } 135 | 136 | func getRunners(services []ServiceDef) ([]ServiceDef, []ServiceDef) { 137 | mainRunners := []ServiceDef{} 138 | secondaryRunners := []ServiceDef{} 139 | 140 | for _, service := range services { 141 | runCfg := service.RunConfig() 142 | 143 | // run config is nil if the service is not a runner 144 | if runCfg == nil { 145 | continue 146 | } 147 | 148 | if runCfg.Wait { 149 | mainRunners = append(mainRunners, service) 150 | } else { 151 | secondaryRunners = append(secondaryRunners, service) 152 | } 153 | } 154 | 155 | return mainRunners, secondaryRunners 156 | } 157 | -------------------------------------------------------------------------------- /lifecycle_interfaces.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import "context" 4 | 5 | // HealthChecker is an optional interface that can be implemented by a service. 6 | type HealthChecker interface { 7 | // HealthCheck is being called when pal is checking the health of the service. 8 | // If returns an error, pal will consider the service unhealthy and try to gracefully Shutdown the app, 9 | // Pal.Run() will return an error. 10 | // ctx has a timeout and only being canceled if it is exceeded. 11 | // 12 | // The healthcheck process works as follows: 13 | // 1. When Pal.HealthCheck() is called Pal initiates the healthcheck sequence. All services are checked concurrently. 14 | // 2. If any service returns an error, Pal initiates a graceful shutdown 15 | // 3. Services can use this method to check their internal state or connections to external systems 16 | // 4. The context provided has a timeout configured via Pal.HealthCheckTimeout() 17 | HealthCheck(ctx context.Context) error 18 | } 19 | 20 | // Shutdowner is an optional interface that can be implemented by a service. 21 | type Shutdowner interface { 22 | // Shutdown is being called when pal is shutting down the service. 23 | // If returns an error, pal will consider this service unhealthy, but will continue to Shutdown the app, 24 | // Pal.Run() will return an error. 25 | // ctx has a timeout and only being canceled if it is exceeded. 26 | // If all the services shutdown successfully, Pal.Run will return nil. 27 | // 28 | // The shutdown process works as follows: 29 | // 1. Whena termination signal is received or the context passed to Pal.Run() is canceled, Pal initiates the shutdown sequence. Services 30 | // are shutdown in dependency order. 31 | // 2. Pal cancels the context for all running services (Runners) and awaits for runners to finish. 32 | // 3. Pal calls Shutdown() on all services that implement this interface in reverse dependency order 33 | // 4. Services should use this method to clean up resources, close connections, etc. 34 | // 5. The context provided has a timeout configured via Pal.ShutdownTimeout() 35 | // 6. If any service returns an error during shutdown, Pal will collect these errors and return them from Run() 36 | Shutdown(ctx context.Context) error 37 | } 38 | 39 | // Initer is an optional interface that can be implemented by a service. 40 | type Initer interface { 41 | // Init is being called when pal is initializing the service, after all the dependencies are injected. 42 | // If returns an error, pal will consider the service unhealthy and try to gracefully Shutdown already initialized services. 43 | // 44 | // The initialization process works as follows: 45 | // 1. During Pal.Init() Pal builds a dependency graph of all registered services 46 | // 2. Pal initializes services in dependency order. 47 | // 3. For each service, Pal injects dependencies and then calls Init() if the service implements this interface 48 | // 4. Services should use this method to perform one-time setup operations like connecting to databases 49 | // 5. The context provided has a timeout configured via Pal.InitTimeout() 50 | // 6. If any service returns an error during initialization, Pal will stop the initialization process 51 | // and attempt to gracefully shut down any already initialized services 52 | Init(ctx context.Context) error 53 | } 54 | 55 | // Runner is a service that can be started in a background goroutine. 56 | // If a service implements this interface, Pal will start this method in a background goroutine when the app is initialized. 57 | // Can be a one-off or long-running task. Services implementing this interface are initialized eagerly. 58 | type Runner interface { 59 | // Run is being called in a background goroutine when Pal is initializing the service, after Init() is called. 60 | // The provided context will be canceled when Pal is shut down, so the service should monitor it and exit gracefully. 61 | // This method should implement the main functionality of background services like HTTP servers, message consumers, etc. 62 | // If this method returns an error, Pal will initiate a graceful shutdown of the application. 63 | Run(ctx context.Context) error 64 | } 65 | -------------------------------------------------------------------------------- /inspect/static/tree.js: -------------------------------------------------------------------------------- 1 | import * as vis from "https://unpkg.com/vis-network/standalone/esm/vis-network.min.mjs"; 2 | 3 | const defaultVisOptions = { 4 | "physics": { 5 | "enabled": false 6 | }, 7 | "layout": { 8 | "randomSeed": 1, 9 | "hierarchical": { 10 | "enabled": true, 11 | "levelSeparation": 150, 12 | "nodeSpacing": 200, 13 | "treeSpacing": 300, 14 | "blockShifting": false, 15 | "edgeMinimization": false, 16 | "parentCentralization": false, 17 | "direction": "UD", 18 | "sortMethod": "directed" 19 | } 20 | }, 21 | "edges": { 22 | "smooth": { 23 | "type": "dynamic", 24 | "roundness": 1 25 | }, 26 | "arrows": { 27 | "to": { 28 | "enabled": true, 29 | "scaleFactor": 1 30 | } 31 | } 32 | } 33 | }; 34 | 35 | function parseOptions(optionsText) { 36 | try { 37 | return JSON.parse(optionsText); 38 | } catch (error) { 39 | return null; 40 | } 41 | } 42 | 43 | function renderTree(options, nodes, edges) { 44 | const container = document.getElementById("tree"); 45 | container.innerHTML = ""; // Clear previous render 46 | 47 | const data = { 48 | nodes: new vis.DataSet(nodes), 49 | edges: new vis.DataSet(edges), 50 | }; 51 | 52 | try { 53 | new vis.Network(container, data, options); 54 | } catch (error) { 55 | console.error("Error rendering network:", error); 56 | } 57 | } 58 | 59 | function updateOptions(nodes, edges) { 60 | const textarea = document.getElementById("options-textarea"); 61 | const optionsText = textarea.value.trim(); 62 | 63 | if (!optionsText) { 64 | renderTree(defaultVisOptions, nodes, edges); 65 | return; 66 | } 67 | 68 | const parsedOptions = parseOptions(optionsText); 69 | if (parsedOptions === null) { 70 | // Invalid JSON - don't render anything 71 | const container = document.getElementById("tree"); 72 | container.innerHTML = ""; 73 | return; 74 | } 75 | 76 | renderTree(parsedOptions, nodes, edges); 77 | } 78 | 79 | async function fetchTree() { 80 | return await fetch("/pal/tree.json").then(res => res.json()); 81 | } 82 | 83 | function sortNodes(nodes) { 84 | nodes.sort((a, b) => a.inDegree - b.inDegree); 85 | nodes.sort((a, b) => a.id.localeCompare(b.id)); 86 | } 87 | 88 | function createNodeTitleTable(node) { 89 | // Create HTML table for node tooltip using template 90 | const template = document.getElementById("node-table-template"); 91 | const tableClone = template.content.cloneNode(true); 92 | 93 | const setValue = (selector, value) => tableClone.querySelector(selector).textContent = value; 94 | 95 | // Fill in the table with node properties 96 | setValue(".node-id", node.id); 97 | setValue(".node-in-degree", node.inDegree); 98 | setValue(".node-out-degree", node.outDegree); 99 | setValue(".node-initer", node.initer); 100 | setValue(".node-runner", node.runner); 101 | setValue(".node-health-checker", node.healthChecker); 102 | setValue(".node-shutdowner", node.shutdowner); 103 | 104 | // Convert the cloned content to HTML string for the title 105 | const tempDiv = document.createElement('div'); 106 | tempDiv.appendChild(tableClone); 107 | return tempDiv; 108 | } 109 | 110 | function applyNodeStyle(node) { 111 | node.title = createNodeTitleTable(node); 112 | 113 | if (node.runner) { 114 | node.shape = "box"; 115 | } 116 | 117 | if (!node.runner && node.inDegree === 0) { 118 | node.color = "red"; 119 | } 120 | 121 | // make pal components less visible 122 | if (node.id.includes("github.com/zhulik/pal")) { 123 | node.opacity = 0.5; 124 | } 125 | 126 | } 127 | 128 | (async () => { 129 | // Initialize textarea with default options 130 | const textarea = document.getElementById("options-textarea"); 131 | textarea.value = JSON.stringify(defaultVisOptions, null, 2); 132 | 133 | const { nodes, edges } = await fetchTree(); 134 | 135 | sortNodes(nodes); 136 | 137 | nodes.forEach(applyNodeStyle); 138 | 139 | // Set up event listener for textarea changes 140 | textarea.addEventListener('input', () => updateOptions(nodes, edges)); 141 | 142 | // Initial render 143 | renderTree(defaultVisOptions, nodes, edges); 144 | })(); -------------------------------------------------------------------------------- /tags_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | "github.com/zhulik/pal" 8 | ) 9 | 10 | // TestParseTag tests the ParseTag function 11 | func TestParseTag(t *testing.T) { 12 | t.Parallel() 13 | 14 | t.Run("parses single tag without value", func(t *testing.T) { 15 | t.Parallel() 16 | 17 | tags, err := pal.ParseTag("skip") 18 | 19 | assert.NoError(t, err) 20 | assert.Equal(t, map[pal.Tag]string{ 21 | pal.TagSkip: "", 22 | }, tags) 23 | }) 24 | 25 | t.Run("parses single tag with value", func(t *testing.T) { 26 | t.Parallel() 27 | 28 | tags, err := pal.ParseTag("name=MyService") 29 | 30 | assert.NoError(t, err) 31 | assert.Equal(t, map[pal.Tag]string{ 32 | pal.TagName: "MyService", 33 | }, tags) 34 | }) 35 | 36 | t.Run("parses multiple tags without values", func(t *testing.T) { 37 | t.Parallel() 38 | 39 | tags, err := pal.ParseTag("skip,match_interface") 40 | 41 | assert.NoError(t, err) 42 | assert.Equal(t, map[pal.Tag]string{ 43 | pal.TagSkip: "", 44 | pal.TagMatchInterface: "", 45 | }, tags) 46 | }) 47 | 48 | t.Run("parses multiple tags with values", func(t *testing.T) { 49 | t.Parallel() 50 | 51 | tags, err := pal.ParseTag("name=MyService,match_interface=MyInterface") 52 | 53 | assert.NoError(t, err) 54 | assert.Equal(t, map[pal.Tag]string{ 55 | pal.TagName: "MyService", 56 | pal.TagMatchInterface: "MyInterface", 57 | }, tags) 58 | }) 59 | 60 | t.Run("parses mixed tags with and without values", func(t *testing.T) { 61 | t.Parallel() 62 | 63 | tags, err := pal.ParseTag("skip,name=MyService,match_interface") 64 | 65 | assert.NoError(t, err) 66 | assert.Equal(t, map[pal.Tag]string{ 67 | pal.TagSkip: "", 68 | pal.TagName: "MyService", 69 | pal.TagMatchInterface: "", 70 | }, tags) 71 | }) 72 | 73 | t.Run("handles empty input as unsupported tag", func(t *testing.T) { 74 | t.Parallel() 75 | 76 | tags, err := pal.ParseTag("") 77 | 78 | assert.NoError(t, err) 79 | assert.Empty(t, tags) 80 | }) 81 | 82 | t.Run("handles whitespace around tags as unsupported tags", func(t *testing.T) { 83 | t.Parallel() 84 | 85 | tags, err := pal.ParseTag(" skip , name=MyService ") 86 | 87 | assert.NoError(t, err) 88 | assert.Equal(t, map[pal.Tag]string{ 89 | pal.TagSkip: "", 90 | pal.TagName: "MyService", 91 | }, tags) 92 | }) 93 | 94 | t.Run("returns error for unsupported tag", func(t *testing.T) { 95 | t.Parallel() 96 | 97 | _, err := pal.ParseTag("unsupported") 98 | 99 | assert.Error(t, err) 100 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 101 | assert.Contains(t, err.Error(), "tag unsupported unsupported") 102 | }) 103 | 104 | t.Run("returns error for unsupported tag in multiple tags", func(t *testing.T) { 105 | t.Parallel() 106 | 107 | _, err := pal.ParseTag("skip,unsupported,name=MyService") 108 | 109 | assert.Error(t, err) 110 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 111 | assert.Contains(t, err.Error(), "tag unsupported unsupported") 112 | }) 113 | 114 | t.Run("handles tag with multiple equals signs correctly", func(t *testing.T) { 115 | t.Parallel() 116 | 117 | _, err := pal.ParseTag("name=key=value=extra") 118 | 119 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 120 | assert.Contains(t, err.Error(), "tag is malformed name=key=value=extra") 121 | }) 122 | 123 | t.Run("returns error for tag with only equals", func(t *testing.T) { 124 | t.Parallel() 125 | 126 | _, err := pal.ParseTag("=") 127 | 128 | assert.Error(t, err) 129 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 130 | assert.Contains(t, err.Error(), "tag unsupported ") 131 | }) 132 | 133 | t.Run("handles tag with multiple equals", func(t *testing.T) { 134 | t.Parallel() 135 | 136 | _, err := pal.ParseTag("name==") 137 | 138 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 139 | }) 140 | 141 | t.Run("handles tag with no value correctly", func(t *testing.T) { 142 | t.Parallel() 143 | 144 | _, err := pal.ParseTag("name=") 145 | 146 | assert.ErrorIs(t, err, pal.ErrInvalidTag) 147 | }) 148 | 149 | t.Run("handles all supported tags", func(t *testing.T) { 150 | t.Parallel() 151 | 152 | tags, err := pal.ParseTag("skip,name=TestService,match_interface=TestInterface") 153 | 154 | assert.NoError(t, err) 155 | assert.Equal(t, map[pal.Tag]string{ 156 | pal.TagSkip: "", 157 | pal.TagName: "TestService", 158 | pal.TagMatchInterface: "TestInterface", 159 | }, tags) 160 | }) 161 | 162 | t.Run("handles duplicate tags by overwriting", func(t *testing.T) { 163 | t.Parallel() 164 | 165 | tags, err := pal.ParseTag("name=FirstService,name=SecondService") 166 | 167 | assert.NoError(t, err) 168 | assert.Equal(t, map[pal.Tag]string{ 169 | pal.TagName: "SecondService", 170 | }, tags) 171 | }) 172 | } 173 | -------------------------------------------------------------------------------- /service_factory1_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/zhulik/pal" 9 | ) 10 | 11 | type factory1Service struct { 12 | Name string 13 | } 14 | 15 | type serviceWithFactoryServiceDependency struct { 16 | Dependency *factory1Service 17 | } 18 | 19 | type serviceWithFactoryFunctionDependency struct { 20 | CreateDependency func(ctx context.Context, name string) (*factory1Service, error) 21 | MustCreateDependency func(ctx context.Context, name string) *factory1Service 22 | } 23 | 24 | // TestService_Instance tests the Instance method of the service struct 25 | func TestServiceFactory1_Invocation(t *testing.T) { 26 | t.Parallel() 27 | 28 | t.Run("when invoked with correct arguments, returns a new instance built with given arguments", func(t *testing.T) { 29 | t.Parallel() 30 | 31 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 32 | return &factory1Service{Name: name}, nil 33 | }) 34 | p := newPal(service) 35 | 36 | ctx := pal.WithPal(t.Context(), p) 37 | 38 | err := p.Init(t.Context()) 39 | assert.NoError(t, err) 40 | 41 | instance1, err := p.Invoke(ctx, service.Name(), "test") 42 | 43 | assert.NoError(t, err) 44 | assert.NotNil(t, instance1) 45 | 46 | assert.Equal(t, "test", instance1.(*factory1Service).Name) 47 | 48 | instance2, err := p.Invoke(ctx, service.Name(), "test2") 49 | 50 | assert.NoError(t, err) 51 | assert.NotNil(t, instance1) 52 | assert.Equal(t, "test2", instance2.(*factory1Service).Name) 53 | 54 | assert.NotSame(t, instance1, instance2) 55 | }) 56 | 57 | t.Run("when invoked with incorrect number of arguments, returns an error", func(t *testing.T) { 58 | t.Parallel() 59 | 60 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 61 | return &factory1Service{Name: name}, nil 62 | }) 63 | p := newPal(service) 64 | 65 | ctx := pal.WithPal(t.Context(), p) 66 | 67 | err := p.Init(t.Context()) 68 | assert.NoError(t, err) 69 | 70 | _, err = p.Invoke(ctx, service.Name()) 71 | 72 | assert.ErrorIs(t, err, pal.ErrServiceInvalidArgumentsCount) 73 | }) 74 | 75 | t.Run("when invoked with incorrect argument type, returns an error", func(t *testing.T) { 76 | t.Parallel() 77 | 78 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 79 | return &factory1Service{Name: name}, nil 80 | }) 81 | p := newPal(service) 82 | 83 | ctx := pal.WithPal(t.Context(), p) 84 | 85 | err := p.Init(t.Context()) 86 | assert.NoError(t, err) 87 | 88 | _, err = p.Invoke(ctx, service.Name(), 1) 89 | 90 | assert.ErrorIs(t, err, pal.ErrServiceInvalidArgumentType) 91 | }) 92 | 93 | t.Run("when a service with a factory service dependency is invoked, returns an error", func(t *testing.T) { 94 | t.Parallel() 95 | 96 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 97 | return &factory1Service{Name: name}, nil 98 | }) 99 | p := newPal(service) 100 | 101 | ctx := pal.WithPal(t.Context(), p) 102 | 103 | err := p.Init(t.Context()) 104 | assert.NoError(t, err) 105 | 106 | err = p.InjectInto(ctx, &serviceWithFactoryServiceDependency{}) 107 | 108 | assert.ErrorIs(t, err, pal.ErrFactoryServiceDependency) 109 | }) 110 | 111 | t.Run("when invoked via injected factory function, returns a new instance built with given arguments", func(t *testing.T) { 112 | t.Parallel() 113 | 114 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 115 | return &factory1Service{Name: name}, nil 116 | }) 117 | p := newPal(service) 118 | 119 | ctx := pal.WithPal(t.Context(), p) 120 | 121 | err := p.Init(t.Context()) 122 | assert.NoError(t, err) 123 | 124 | serviceWithFactoryFn := &serviceWithFactoryFunctionDependency{} 125 | err = p.InjectInto(ctx, serviceWithFactoryFn) 126 | 127 | assert.NoError(t, err) 128 | 129 | dependency, err := serviceWithFactoryFn.CreateDependency(ctx, "test") 130 | 131 | assert.NoError(t, err) 132 | assert.Equal(t, "test", dependency.Name) 133 | }) 134 | 135 | t.Run("when invoked via injected must factory function, returns a new instance built with given arguments", func(t *testing.T) { 136 | t.Parallel() 137 | 138 | service := pal.ProvideFactory1[*factory1Service](func(_ context.Context, name string) (*factory1Service, error) { 139 | return &factory1Service{Name: name}, nil 140 | }) 141 | p := newPal(service) 142 | 143 | ctx := pal.WithPal(t.Context(), p) 144 | 145 | err := p.Init(t.Context()) 146 | assert.NoError(t, err) 147 | 148 | serviceWithFactoryFn := &serviceWithFactoryFunctionDependency{} 149 | err = p.InjectInto(ctx, serviceWithFactoryFn) 150 | 151 | assert.NoError(t, err) 152 | 153 | dependency := serviceWithFactoryFn.MustCreateDependency(ctx, "test") 154 | 155 | assert.Equal(t, "test", dependency.Name) 156 | }) 157 | } 158 | -------------------------------------------------------------------------------- /pkg/dag/dag.go: -------------------------------------------------------------------------------- 1 | package dag 2 | 3 | import ( 4 | "cmp" 5 | "errors" 6 | "iter" 7 | "maps" 8 | "slices" 9 | ) 10 | 11 | var ( 12 | ErrEdgeAlreadyExists = errors.New("edge already exists") 13 | ErrCycleDetected = errors.New("cycle detected") 14 | ErrVertexNotFound = errors.New("vertex not found") 15 | ) 16 | 17 | type DAG[ID cmp.Ordered, T any] struct { 18 | vertices map[ID]T 19 | edges map[ID]map[ID]bool // adjacency list: source -> set of targets 20 | inDegree map[ID]int // in-degree count for each vertex 21 | } 22 | 23 | func New[ID cmp.Ordered, T any]() *DAG[ID, T] { 24 | return &DAG[ID, T]{ 25 | vertices: make(map[ID]T), 26 | edges: make(map[ID]map[ID]bool), 27 | inDegree: make(map[ID]int), 28 | } 29 | } 30 | 31 | // Vertices returns the vertices of the DAG 32 | func (d *DAG[ID, T]) Vertices() map[ID]T { 33 | return d.vertices 34 | } 35 | 36 | // Edges returns the edges of the DAG 37 | func (d *DAG[ID, T]) Edges() map[ID]map[ID]bool { 38 | return d.edges 39 | } 40 | 41 | // VertexCount returns the total number of vertices in the DAG 42 | func (d *DAG[ID, T]) VertexCount() int { 43 | return len(d.vertices) 44 | } 45 | 46 | // EdgeCount returns the total number of edges in the DAG 47 | func (d *DAG[ID, T]) EdgeCount() int { 48 | count := 0 49 | for _, targets := range d.edges { 50 | count += len(targets) 51 | } 52 | return count 53 | } 54 | 55 | // VertexExists checks if a vertex with the given ID exists 56 | func (d *DAG[ID, T]) VertexExists(id ID) bool { 57 | _, exists := d.vertices[id] 58 | return exists 59 | } 60 | 61 | // EdgeExists checks if an edge from source to target exists 62 | func (d *DAG[ID, T]) EdgeExists(source, target ID) bool { 63 | if !d.VertexExists(source) { 64 | return false 65 | } 66 | return d.edges[source][target] 67 | } 68 | 69 | // GetVertex returns the vertex data for the given ID and whether it exists 70 | func (d *DAG[ID, T]) GetVertex(id ID) (T, bool) { 71 | val, exists := d.vertices[id] 72 | return val, exists 73 | } 74 | 75 | // GetInDegree returns the in-degree (number of incoming edges) for a vertex 76 | func (d *DAG[ID, T]) GetInDegree(id ID) int { 77 | return d.inDegree[id] 78 | } 79 | 80 | // GetOutDegree returns the out-degree (number of outgoing edges) for a vertex 81 | func (d *DAG[ID, T]) GetOutDegree(id ID) int { 82 | if !d.VertexExists(id) { 83 | return 0 84 | } 85 | return len(d.edges[id]) 86 | } 87 | 88 | func (d *DAG[ID, T]) AddVertexIfNotExist(id ID, v T) { 89 | if _, exists := d.vertices[id]; !exists { 90 | d.vertices[id] = v 91 | d.edges[id] = make(map[ID]bool) 92 | d.inDegree[id] = 0 93 | } 94 | } 95 | 96 | func (d *DAG[ID, T]) AddEdge(source, target ID) error { 97 | // Check if both vertices exist 98 | if !d.VertexExists(source) { 99 | return ErrVertexNotFound 100 | } 101 | if !d.VertexExists(target) { 102 | return ErrVertexNotFound 103 | } 104 | 105 | // Check if edge already exists 106 | if d.edges[source][target] { 107 | return ErrEdgeAlreadyExists 108 | } 109 | 110 | // Add the edge 111 | d.edges[source][target] = true 112 | d.inDegree[target]++ 113 | 114 | // Check for cycles using DFS 115 | if d.hasCycle() { 116 | // Remove the edge if it creates a cycle 117 | d.edges[source][target] = false 118 | d.inDegree[target]-- 119 | return ErrCycleDetected 120 | } 121 | 122 | return nil 123 | } 124 | 125 | func (d *DAG[ID, T]) AddEdgeIfNotExist(source, target ID) error { 126 | if d.EdgeExists(source, target) { 127 | return nil 128 | } 129 | return d.AddEdge(source, target) 130 | } 131 | 132 | func (d *DAG[ID, T]) TopologicalOrder() iter.Seq2[ID, T] { 133 | // Create a copy of in-degree counts 134 | inDegreeCopy := make(map[ID]int) 135 | maps.Copy(inDegreeCopy, d.inDegree) 136 | 137 | // Find all vertices with in-degree 0 138 | var queue []ID 139 | for id, count := range inDegreeCopy { 140 | if count == 0 { 141 | queue = append(queue, id) 142 | } 143 | } 144 | 145 | // Kahn's algorithm for topological sorting 146 | return func(yield func(ID, T) bool) { 147 | for len(queue) > 0 { 148 | current := queue[0] 149 | queue = queue[1:] 150 | 151 | if !yield(current, d.vertices[current]) { 152 | break 153 | } 154 | 155 | // Reduce in-degree of all neighbors 156 | for neighbor := range d.edges[current] { 157 | inDegreeCopy[neighbor]-- 158 | if inDegreeCopy[neighbor] == 0 { 159 | queue = append(queue, neighbor) 160 | } 161 | } 162 | } 163 | } 164 | } 165 | 166 | func (d *DAG[ID, T]) ReverseTopologicalOrder() iter.Seq2[ID, T] { 167 | var result []ID 168 | for id := range d.TopologicalOrder() { 169 | result = append(result, id) 170 | } 171 | slices.Reverse(result) 172 | 173 | return func(yield func(ID, T) bool) { 174 | for _, id := range result { 175 | if !yield(id, d.vertices[id]) { 176 | break 177 | } 178 | } 179 | } 180 | } 181 | 182 | // Helper method to detect cycles using DFS 183 | func (d *DAG[ID, T]) hasCycle() bool { 184 | visited := make(map[ID]bool) 185 | recStack := make(map[ID]bool) 186 | 187 | var dfs func(ID) bool 188 | dfs = func(vertex ID) bool { 189 | if recStack[vertex] { 190 | return true // Back edge found, cycle detected 191 | } 192 | if visited[vertex] { 193 | return false // Already processed 194 | } 195 | 196 | visited[vertex] = true 197 | recStack[vertex] = true 198 | 199 | for neighbor := range d.edges[vertex] { 200 | if dfs(neighbor) { 201 | return true 202 | } 203 | } 204 | 205 | recStack[vertex] = false 206 | return false 207 | } 208 | 209 | for vertex := range d.vertices { 210 | if !visited[vertex] { 211 | if dfs(vertex) { 212 | return true 213 | } 214 | } 215 | } 216 | 217 | return false 218 | } 219 | -------------------------------------------------------------------------------- /pkg/dag/dag_test.go: -------------------------------------------------------------------------------- 1 | package dag_test 2 | 3 | import ( 4 | "slices" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/zhulik/pal/pkg/dag" 9 | ) 10 | 11 | func TestNew(t *testing.T) { 12 | t.Parallel() 13 | 14 | d := dag.New[string, int]() 15 | assert.NotNil(t, d) 16 | assert.Equal(t, 0, d.VertexCount()) 17 | assert.Equal(t, 0, d.EdgeCount()) 18 | } 19 | 20 | func TestAddVertexIfNotExist(t *testing.T) { 21 | t.Parallel() 22 | 23 | t.Run("adds first vertex", func(t *testing.T) { 24 | t.Parallel() 25 | d := dag.New[string, int]() 26 | 27 | d.AddVertexIfNotExist("A", 1) 28 | assert.Equal(t, 1, d.VertexCount()) 29 | assert.True(t, d.VertexExists("A")) 30 | 31 | val, exists := d.GetVertex("A") 32 | assert.True(t, exists) 33 | assert.Equal(t, 1, val) 34 | }) 35 | 36 | t.Run("adds second vertex", func(t *testing.T) { 37 | t.Parallel() 38 | d := dag.New[string, int]() 39 | 40 | d.AddVertexIfNotExist("A", 1) 41 | d.AddVertexIfNotExist("B", 2) 42 | assert.Equal(t, 2, d.VertexCount()) 43 | assert.True(t, d.VertexExists("A")) 44 | assert.True(t, d.VertexExists("B")) 45 | 46 | valA, existsA := d.GetVertex("A") 47 | assert.True(t, existsA) 48 | assert.Equal(t, 1, valA) 49 | 50 | valB, existsB := d.GetVertex("B") 51 | assert.True(t, existsB) 52 | assert.Equal(t, 2, valB) 53 | }) 54 | 55 | t.Run("does not change existing vertex", func(t *testing.T) { 56 | t.Parallel() 57 | d := dag.New[string, int]() 58 | 59 | d.AddVertexIfNotExist("A", 1) 60 | d.AddVertexIfNotExist("A", 999) 61 | 62 | val, exists := d.GetVertex("A") 63 | assert.True(t, exists) 64 | assert.Equal(t, 1, val) 65 | }) 66 | } 67 | 68 | func TestAddEdge(t *testing.T) { 69 | t.Parallel() 70 | 71 | t.Run("adds edge between existing vertices", func(t *testing.T) { 72 | t.Parallel() 73 | d := dag.New[string, int]() 74 | 75 | d.AddVertexIfNotExist("A", 1) 76 | d.AddVertexIfNotExist("B", 2) 77 | 78 | err := d.AddEdge("A", "B") 79 | assert.NoError(t, err) 80 | assert.True(t, d.EdgeExists("A", "B")) 81 | assert.Equal(t, 1, d.GetInDegree("B")) 82 | assert.Equal(t, 1, d.GetOutDegree("A")) 83 | assert.Equal(t, 1, d.EdgeCount()) 84 | }) 85 | 86 | t.Run("returns error for duplicate edge", func(t *testing.T) { 87 | t.Parallel() 88 | d := dag.New[string, int]() 89 | 90 | d.AddVertexIfNotExist("A", 1) 91 | d.AddVertexIfNotExist("B", 2) 92 | 93 | err := d.AddEdge("A", "B") 94 | assert.NoError(t, err) 95 | 96 | err = d.AddEdge("A", "B") 97 | assert.ErrorIs(t, err, dag.ErrEdgeAlreadyExists) 98 | }) 99 | 100 | t.Run("does not allow adding edge from non-existent vertex", func(t *testing.T) { 101 | t.Parallel() 102 | d := dag.New[string, int]() 103 | 104 | err := d.AddEdge("X", "Y") 105 | assert.ErrorIs(t, err, dag.ErrVertexNotFound) 106 | }) 107 | 108 | t.Run("does not allow adding edge to non-existent vertex", func(t *testing.T) { 109 | t.Parallel() 110 | d := dag.New[string, int]() 111 | 112 | d.AddVertexIfNotExist("A", 1) 113 | 114 | err := d.AddEdge("A", "Y") 115 | assert.ErrorIs(t, err, dag.ErrVertexNotFound) 116 | }) 117 | 118 | t.Run("prevents simple cycle", func(t *testing.T) { 119 | t.Parallel() 120 | d := dag.New[string, int]() 121 | 122 | d.AddVertexIfNotExist("A", 1) 123 | d.AddVertexIfNotExist("B", 2) 124 | d.AddVertexIfNotExist("C", 3) 125 | 126 | // Add edges that don't create cycles 127 | err := d.AddEdge("A", "B") 128 | assert.NoError(t, err) 129 | 130 | err = d.AddEdge("B", "C") 131 | assert.NoError(t, err) 132 | 133 | // Try to add edge that creates cycle 134 | err = d.AddEdge("C", "A") 135 | assert.ErrorIs(t, err, dag.ErrCycleDetected) 136 | 137 | // Verify the cycle-causing edge was not added 138 | assert.False(t, d.EdgeExists("C", "A")) 139 | assert.Equal(t, 0, d.GetInDegree("A")) 140 | }) 141 | 142 | t.Run("prevents self-loop", func(t *testing.T) { 143 | t.Parallel() 144 | d := dag.New[string, int]() 145 | 146 | d.AddVertexIfNotExist("A", 1) 147 | 148 | err := d.AddEdge("A", "A") 149 | assert.ErrorIs(t, err, dag.ErrCycleDetected) 150 | assert.False(t, d.EdgeExists("A", "A")) 151 | assert.Equal(t, 0, d.GetInDegree("A")) 152 | }) 153 | } 154 | 155 | func TestTopologicalOrder(t *testing.T) { 156 | t.Parallel() 157 | 158 | t.Run("simple chain", func(t *testing.T) { 159 | t.Parallel() 160 | d := dag.New[string, int]() 161 | 162 | d.AddVertexIfNotExist("A", 1) 163 | d.AddVertexIfNotExist("B", 2) 164 | d.AddVertexIfNotExist("C", 3) 165 | 166 | err := d.AddEdge("A", "B") 167 | assert.NoError(t, err) 168 | 169 | err = d.AddEdge("B", "C") 170 | assert.NoError(t, err) 171 | 172 | var result []string 173 | for id := range d.TopologicalOrder() { 174 | result = append(result, id) 175 | } 176 | 177 | assert.Equal(t, []string{"A", "B", "C"}, result) 178 | }) 179 | 180 | t.Run("multiple paths", func(t *testing.T) { 181 | t.Parallel() 182 | d := dag.New[string, int]() 183 | 184 | // Create a more complex DAG: 185 | // A 186 | // / \ 187 | // B C 188 | // \ / 189 | // D 190 | d.AddVertexIfNotExist("A", 1) 191 | d.AddVertexIfNotExist("B", 2) 192 | d.AddVertexIfNotExist("C", 3) 193 | d.AddVertexIfNotExist("D", 4) 194 | 195 | err := d.AddEdge("A", "B") 196 | assert.NoError(t, err) 197 | 198 | err = d.AddEdge("A", "C") 199 | assert.NoError(t, err) 200 | 201 | err = d.AddEdge("B", "D") 202 | assert.NoError(t, err) 203 | 204 | err = d.AddEdge("C", "D") 205 | assert.NoError(t, err) 206 | 207 | var result []string 208 | for id := range d.TopologicalOrder() { 209 | result = append(result, id) 210 | } 211 | 212 | Ai := slices.Index(result, "A") 213 | Bi := slices.Index(result, "B") 214 | Ci := slices.Index(result, "C") 215 | Di := slices.Index(result, "D") 216 | 217 | assert.True(t, Ai < Bi) 218 | assert.True(t, Ai < Ci) 219 | assert.True(t, Bi < Di) 220 | assert.True(t, Ci < Di) 221 | }) 222 | 223 | t.Run("empty DAG", func(t *testing.T) { 224 | t.Parallel() 225 | d := dag.New[string, int]() 226 | 227 | count := 0 228 | for range d.TopologicalOrder() { 229 | count++ 230 | } 231 | 232 | assert.Equal(t, 0, count) 233 | }) 234 | 235 | t.Run("single vertex", func(t *testing.T) { 236 | t.Parallel() 237 | d := dag.New[string, int]() 238 | 239 | d.AddVertexIfNotExist("A", 1) 240 | 241 | var result []string 242 | for id := range d.TopologicalOrder() { 243 | result = append(result, id) 244 | } 245 | 246 | assert.Len(t, result, 1) 247 | assert.Equal(t, "A", result[0]) 248 | }) 249 | } 250 | 251 | func TestReverseTopologicalOrder(t *testing.T) { 252 | t.Parallel() 253 | 254 | t.Run("simple chain", func(t *testing.T) { 255 | t.Parallel() 256 | d := dag.New[string, int]() 257 | 258 | d.AddVertexIfNotExist("A", 1) 259 | d.AddVertexIfNotExist("B", 2) 260 | d.AddVertexIfNotExist("C", 3) 261 | 262 | err := d.AddEdge("A", "B") 263 | assert.NoError(t, err) 264 | 265 | err = d.AddEdge("B", "C") 266 | assert.NoError(t, err) 267 | 268 | var result []string 269 | for id := range d.ReverseTopologicalOrder() { 270 | result = append(result, id) 271 | } 272 | 273 | assert.Equal(t, []string{"C", "B", "A"}, result) 274 | }) 275 | } 276 | -------------------------------------------------------------------------------- /runners_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "maps" 6 | "slices" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/mock" 11 | "github.com/zhulik/pal" 12 | ) 13 | 14 | type MainRunner any 15 | 16 | func TestRunServices(t *testing.T) { 17 | t.Run("returns error if no main runners are given", func(t *testing.T) { 18 | t.Parallel() 19 | 20 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 21 | s := NewMockRunnerServiceStruct(t) 22 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 23 | return s, nil 24 | }) 25 | 26 | p := newPal(secondaryRunner) 27 | assert.NoError(t, p.Init(t.Context())) 28 | 29 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 30 | assert.ErrorIs(t, err, pal.ErrNoMainRunners) 31 | }) 32 | 33 | t.Run("returns nil if main and secondary runners finish successfully", func(t *testing.T) { 34 | t.Parallel() 35 | 36 | mainRunner := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 37 | s := NewMockRunnerServiceStruct(t) 38 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 39 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 40 | return s, nil 41 | }) 42 | 43 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 44 | s := NewMockRunnerServiceStruct(t) 45 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 46 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 47 | return s, nil 48 | }) 49 | 50 | p := newPal(mainRunner, secondaryRunner) 51 | assert.NoError(t, p.Init(t.Context())) 52 | 53 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 54 | assert.NoError(t, err) 55 | }) 56 | 57 | t.Run("returns nil if multiple main runners finish successfully", func(t *testing.T) { 58 | t.Parallel() 59 | 60 | mainRunner1 := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 61 | s := NewMockRunnerServiceStruct(t) 62 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 63 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 64 | return s, nil 65 | }) 66 | 67 | mainRunner2 := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 68 | s := NewMockRunnerServiceStruct(t) 69 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 70 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 71 | return s, nil 72 | }) 73 | 74 | p := newPal(mainRunner1, mainRunner2) 75 | assert.NoError(t, p.Init(t.Context())) 76 | 77 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 78 | assert.NoError(t, err) 79 | }) 80 | 81 | t.Run("returns err if main runners blocks and secondary runner fails", func(t *testing.T) { 82 | t.Parallel() 83 | 84 | var mainCompleted bool 85 | 86 | mainRunner := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 87 | s := NewMockRunnerServiceStruct(t) 88 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 89 | s.MockRunner.EXPECT().Run(mock.Anything).Return(context.Canceled).Run(func(ctx context.Context) { 90 | <-ctx.Done() 91 | mainCompleted = true 92 | }) 93 | return s, nil 94 | }) 95 | 96 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 97 | s := NewMockRunnerServiceStruct(t) 98 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 99 | s.MockRunner.EXPECT().Run(mock.Anything).Return(errTest) 100 | return s, nil 101 | }) 102 | 103 | p := newPal(mainRunner, secondaryRunner) 104 | assert.NoError(t, p.Init(t.Context())) 105 | 106 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 107 | assert.ErrorIs(t, err, errTest) 108 | assert.True(t, mainCompleted) 109 | }) 110 | 111 | t.Run("returns err if main runner finishes successfully and secondary runner blocks", func(t *testing.T) { 112 | t.Parallel() 113 | 114 | var secondaryCompleted bool 115 | 116 | mainRunner := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 117 | s := NewMockRunnerServiceStruct(t) 118 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 119 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 120 | return s, nil 121 | }) 122 | 123 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 124 | s := NewMockRunnerServiceStruct(t) 125 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 126 | s.MockRunner.EXPECT().Run(mock.Anything).Return(context.Canceled).Run(func(ctx context.Context) { 127 | <-ctx.Done() 128 | secondaryCompleted = true 129 | }) 130 | return s, nil 131 | }) 132 | 133 | p := newPal(mainRunner, secondaryRunner) 134 | assert.NoError(t, p.Init(t.Context())) 135 | 136 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 137 | assert.NoError(t, err) 138 | assert.True(t, secondaryCompleted) 139 | }) 140 | 141 | t.Run("returns err if main runner fails and secondary runner blocks", func(t *testing.T) { 142 | t.Parallel() 143 | 144 | var secondaryCompleted bool 145 | 146 | mainRunner := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 147 | s := NewMockRunnerServiceStruct(t) 148 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 149 | s.MockRunner.EXPECT().Run(mock.Anything).Return(errTest) 150 | return s, nil 151 | }) 152 | 153 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 154 | s := NewMockRunnerServiceStruct(t) 155 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 156 | s.MockRunner.EXPECT().Run(mock.Anything).Return(context.Canceled).Run(func(ctx context.Context) { 157 | <-ctx.Done() 158 | secondaryCompleted = true 159 | }) 160 | return s, nil 161 | }) 162 | 163 | p := newPal(mainRunner, secondaryRunner) 164 | assert.NoError(t, p.Init(t.Context())) 165 | 166 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 167 | assert.ErrorIs(t, err, errTest) 168 | assert.True(t, secondaryCompleted) 169 | }) 170 | 171 | t.Run("returns a joined err if main main and secondary runners fail", func(t *testing.T) { 172 | t.Parallel() 173 | 174 | mainRunner := pal.ProvideFn[MainRunner](func(context.Context) (*RunnerServiceStruct, error) { 175 | s := NewMockRunnerServiceStruct(t) 176 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 177 | s.MockRunner.EXPECT().Run(mock.Anything).Return(errTest) 178 | return s, nil 179 | }) 180 | 181 | secondaryRunner := pal.ProvideFn[*RunnerServiceStruct](func(context.Context) (*RunnerServiceStruct, error) { 182 | s := NewMockRunnerServiceStruct(t) 183 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: false}) 184 | s.MockRunner.EXPECT().Run(mock.Anything).Return(errTest2) 185 | return s, nil 186 | }) 187 | 188 | p := newPal(mainRunner, secondaryRunner) 189 | assert.NoError(t, p.Init(t.Context())) 190 | 191 | err := pal.RunServices(t.Context(), slices.Collect(maps.Values(p.Services()))) 192 | assert.ErrorIs(t, err, errTest) 193 | assert.ErrorIs(t, err, errTest2) 194 | }) 195 | } 196 | -------------------------------------------------------------------------------- /container_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/zhulik/pal" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func NewMockService(t *testing.T, name string) *MockServiceDef { 13 | mock := NewMockServiceDef(t) 14 | 15 | mock.EXPECT().Name().Return(name).Maybe() 16 | mock.EXPECT().Dependencies().Return(nil).Maybe() 17 | mock.EXPECT().Make().Return(nil).Maybe() 18 | mock.EXPECT().Arguments().Return(0).Maybe() 19 | 20 | return mock 21 | } 22 | 23 | // TestContainer_New tests the New function for Container 24 | func TestContainer_New(t *testing.T) { 25 | t.Parallel() 26 | 27 | t.Run("creates a new Container with services", func(t *testing.T) { 28 | t.Parallel() 29 | 30 | c := pal.NewContainer( 31 | &pal.Pal{}, 32 | NewMockService(t, "service1"), 33 | NewMockService(t, "service2"), 34 | ) 35 | 36 | assert.NotNil(t, c) 37 | }) 38 | 39 | t.Run("creates a new Container with empty services", func(t *testing.T) { 40 | t.Parallel() 41 | 42 | c := pal.NewContainer(&pal.Pal{}) 43 | 44 | assert.NotNil(t, c) 45 | // We can verify it works with nil services by checking that Services() returns empty 46 | assert.Empty(t, c.Services()) 47 | }) 48 | } 49 | 50 | // TestContainer_Init tests the Init method of Container 51 | func TestContainer_Init(t *testing.T) { 52 | t.Parallel() 53 | 54 | t.Run("initializes singleton services successfully", func(t *testing.T) { 55 | t.Parallel() 56 | 57 | service1 := NewMockService(t, "service1") 58 | service2 := NewMockService(t, "service2") 59 | service3 := NewMockService(t, "service3") 60 | 61 | service1.EXPECT().Init(t.Context()).Return(nil) 62 | service2.EXPECT().Init(t.Context()).Return(nil) 63 | service3.EXPECT().Init(t.Context()).Return(nil) 64 | 65 | c := pal.NewContainer(&pal.Pal{}, service1, service2, service3) 66 | 67 | err := c.Init(t.Context()) 68 | 69 | assert.NoError(t, err) 70 | }) 71 | 72 | t.Run("returns error when service initialization fails", func(t *testing.T) { 73 | t.Parallel() 74 | 75 | service1 := NewMockService(t, "service1") 76 | service2 := NewMockService(t, "service2") 77 | 78 | service1.EXPECT().Init(t.Context()).Return(nil).Maybe() // Init order is not guaranteed 79 | service2.EXPECT().Init(t.Context()).Return(errTest).Once() 80 | 81 | c := pal.NewContainer(&pal.Pal{}, service1, service2) 82 | 83 | err := c.Init(t.Context()) 84 | 85 | assert.ErrorIs(t, err, errTest) 86 | }) 87 | } 88 | 89 | // TestContainer_Invoke tests the Invoke method of Container 90 | func TestContainer_Invoke(t *testing.T) { 91 | t.Parallel() 92 | 93 | t.Run("invokes service successfully", func(t *testing.T) { 94 | t.Parallel() 95 | 96 | expectedInstance := struct{}{} 97 | 98 | service := NewMockService(t, "service1") 99 | service.EXPECT().Init(t.Context()).Return(nil) 100 | service.EXPECT().Instance(t.Context()).Return(expectedInstance, nil) 101 | 102 | c := pal.NewContainer(&pal.Pal{}, service) 103 | require.NoError(t, c.Init(t.Context())) 104 | 105 | instance, err := c.Invoke(t.Context(), "service1") 106 | 107 | assert.NoError(t, err) 108 | assert.Exactly(t, expectedInstance, instance) 109 | }) 110 | 111 | t.Run("returns error when service not found", func(t *testing.T) { 112 | t.Parallel() 113 | 114 | c := pal.NewContainer(&pal.Pal{}) 115 | 116 | _, err := c.Invoke(t.Context(), "nonexistent") 117 | 118 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 119 | }) 120 | 121 | t.Run("returns error when service instance creation fails", func(t *testing.T) { 122 | t.Parallel() 123 | 124 | service := NewMockService(t, "service1") 125 | service.EXPECT().Init(t.Context()).Return(nil) 126 | service.EXPECT().Instance(t.Context()).Return(nil, errTest) 127 | 128 | c := pal.NewContainer(&pal.Pal{}, service) 129 | require.NoError(t, c.Init(t.Context())) 130 | 131 | _, err := c.Invoke(t.Context(), "service1") 132 | 133 | assert.ErrorIs(t, err, pal.ErrServiceInitFailed) 134 | }) 135 | } 136 | 137 | // TestContainer_Shutdown tests the Shutdown method of Container 138 | func TestContainer_Shutdown(t *testing.T) { 139 | t.Parallel() 140 | 141 | t.Run("shuts down all singleton services successfully", func(t *testing.T) { 142 | t.Parallel() 143 | 144 | service1 := NewMockService(t, "service1") 145 | service2 := NewMockService(t, "service2") 146 | service3 := NewMockService(t, "service3") 147 | 148 | service1.EXPECT().Init(t.Context()).Return(nil) 149 | service2.EXPECT().Init(t.Context()).Return(nil) 150 | service3.EXPECT().Init(t.Context()).Return(nil) 151 | 152 | service1.EXPECT().Shutdown(t.Context()).Return(nil) 153 | service2.EXPECT().Shutdown(t.Context()).Return(nil) 154 | service3.EXPECT().Shutdown(t.Context()).Return(nil) 155 | 156 | c := pal.NewContainer(&pal.Pal{}, service1, service2, service3) 157 | require.NoError(t, c.Init(t.Context())) 158 | 159 | err := c.Shutdown(t.Context()) 160 | 161 | assert.NoError(t, err) 162 | }) 163 | 164 | t.Run("returns error when service shutdown fails", func(t *testing.T) { 165 | t.Parallel() 166 | 167 | service := NewMockService(t, "service1") 168 | service.EXPECT().Init(t.Context()).Return(nil) 169 | service.EXPECT().Shutdown(t.Context()).Return(errTest) 170 | 171 | c := pal.NewContainer(&pal.Pal{}, service) 172 | require.NoError(t, c.Init(t.Context())) 173 | 174 | err := c.Shutdown(t.Context()) 175 | 176 | assert.ErrorIs(t, err, errTest) 177 | }) 178 | } 179 | 180 | // TestContainer_HealthCheck tests the HealthCheck method of Container 181 | func TestContainer_HealthCheck(t *testing.T) { 182 | t.Parallel() 183 | 184 | t.Run("health checks all singleton services successfully", func(t *testing.T) { 185 | t.Parallel() 186 | 187 | service1 := NewMockService(t, "service1") 188 | service2 := NewMockService(t, "service2") 189 | service3 := NewMockService(t, "service3") 190 | 191 | service1.EXPECT().Init(t.Context()).Return(nil) 192 | service2.EXPECT().Init(t.Context()).Return(nil) 193 | service3.EXPECT().Init(t.Context()).Return(nil) 194 | 195 | service1.EXPECT().HealthCheck(t.Context()).Return(nil) 196 | service2.EXPECT().HealthCheck(t.Context()).Return(nil) 197 | service3.EXPECT().HealthCheck(t.Context()).Return(nil) 198 | 199 | c := pal.NewContainer(&pal.Pal{}, service1, service2, service3) 200 | require.NoError(t, c.Init(t.Context())) 201 | 202 | err := c.HealthCheck(t.Context()) 203 | 204 | assert.NoError(t, err) 205 | }) 206 | 207 | t.Run("returns error when service health check fails", func(t *testing.T) { 208 | t.Parallel() 209 | 210 | service := NewMockService(t, "service1") 211 | service.EXPECT().Init(t.Context()).Return(nil) 212 | service.EXPECT().HealthCheck(t.Context()).Return(errTest) 213 | 214 | c := pal.NewContainer(&pal.Pal{}, service) 215 | require.NoError(t, c.Init(t.Context())) 216 | 217 | err := c.HealthCheck(t.Context()) 218 | 219 | assert.ErrorIs(t, err, errTest) 220 | }) 221 | } 222 | 223 | // TestContainer_Services tests the Services method of Container 224 | func TestContainer_Services(t *testing.T) { 225 | t.Parallel() 226 | 227 | t.Run("returns all services", func(t *testing.T) { 228 | t.Parallel() 229 | 230 | service1 := NewMockService(t, "service1") 231 | service2 := NewMockService(t, "service2") 232 | 233 | service1.EXPECT().Init(t.Context()).Return(nil) 234 | service2.EXPECT().Init(t.Context()).Return(nil) 235 | 236 | c := pal.NewContainer(&pal.Pal{}, service1, service2) 237 | require.NoError(t, c.Init(t.Context())) 238 | 239 | result := c.Services() 240 | 241 | assert.Len(t, result, 2) 242 | assert.Contains(t, result, "service1") 243 | assert.Contains(t, result, "service2") 244 | }) 245 | 246 | t.Run("returns empty map for empty container", func(t *testing.T) { 247 | t.Parallel() 248 | 249 | c := pal.NewContainer(&pal.Pal{}) 250 | 251 | result := c.Services() 252 | 253 | assert.Empty(t, result) 254 | }) 255 | } 256 | -------------------------------------------------------------------------------- /pal.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "os/signal" 10 | "reflect" 11 | "sync/atomic" 12 | "syscall" 13 | "time" 14 | ) 15 | 16 | // ContextKey is a type used for context value keys to avoid collisions. 17 | type ContextKey int 18 | 19 | const ( 20 | // CtxValue is the key used to store and retrieve the Pal instance from a context. 21 | // This allows services to access the Pal instance from a context passed to them. 22 | CtxValue ContextKey = iota 23 | ) 24 | 25 | // DefaultShutdownSignals is the default signals that will be used to shutdown the app. 26 | var DefaultShutdownSignals = []os.Signal{syscall.SIGINT, syscall.SIGTERM} 27 | 28 | var defaultAttrSetters = []SlogAttributeSetter{ 29 | func(target any) (string, string) { 30 | return "component", fmt.Sprintf("%T", target) 31 | }, 32 | } 33 | 34 | // Pal is the main struct that manages the lifecycle of services in the application. 35 | // It handles service initialization, dependency injection, health checking, and graceful shutdown. 36 | // Pal implements the Invoker interface, allowing services to be retrieved from it. 37 | type Pal struct { 38 | config *Config 39 | container *Container 40 | 41 | initialized *atomic.Bool 42 | 43 | logger *slog.Logger 44 | } 45 | 46 | // New creates and returns a new instance of Pal with the provided Services 47 | func New(services ...ServiceDef) *Pal { 48 | pal := &Pal{ 49 | config: &Config{}, 50 | initialized: &atomic.Bool{}, 51 | logger: slog.With("palComponent", "Pal"), 52 | } 53 | 54 | services = append(services, Provide(pal)) 55 | 56 | pal.container = NewContainer(pal, services...) 57 | 58 | return pal 59 | } 60 | 61 | // FromContext retrieves a *Pal from the provided context, expecting it to be stored under the CtxValue key. 62 | func FromContext(ctx context.Context) (*Pal, error) { 63 | invoker, ok := ctx.Value(CtxValue).(*Pal) 64 | if !ok { 65 | return nil, ErrInvokerIsNotInContext 66 | } 67 | 68 | return invoker, nil 69 | } 70 | 71 | // MustFromContext is like FromContext but panics if an error occurs. 72 | func MustFromContext(ctx context.Context) *Pal { 73 | return must(FromContext(ctx)) 74 | } 75 | 76 | func WithPal(ctx context.Context, pal *Pal) context.Context { 77 | return context.WithValue(ctx, CtxValue, pal) 78 | } 79 | 80 | // InitTimeout sets the timeout for the initialization of the services. 81 | func (p *Pal) InitTimeout(t time.Duration) *Pal { 82 | p.config.InitTimeout = t 83 | return p 84 | } 85 | 86 | // HealthCheckTimeout sets the timeout for the healthcheck of the services. 87 | func (p *Pal) HealthCheckTimeout(t time.Duration) *Pal { 88 | p.config.HealthCheckTimeout = t 89 | return p 90 | } 91 | 92 | // ShutdownTimeout sets the timeout for the Shutdown of the services. 93 | func (p *Pal) ShutdownTimeout(t time.Duration) *Pal { 94 | p.config.ShutdownTimeout = t 95 | return p 96 | } 97 | 98 | // InjectSlog enables automatic slog injection into the services. 99 | func (p *Pal) InjectSlog(configs ...SlogAttributeSetter) *Pal { 100 | if len(configs) == 0 { 101 | configs = defaultAttrSetters 102 | } 103 | 104 | p.config.AttrSetters = configs 105 | return p 106 | } 107 | 108 | // RunHealthCheckServer enables the default health check server. 109 | func (p *Pal) RunHealthCheckServer(addr, path string) *Pal { 110 | if p.initialized.Load() { 111 | panic("RunHealthCheckServer can only be called before Init") 112 | } 113 | 114 | p.container.addService( 115 | Provide[palHealthCheckServer](&healthCheckServer{ 116 | addr: addr, 117 | path: path, 118 | }), 119 | ) 120 | 121 | return p 122 | } 123 | 124 | // HealthCheck verifies the health of the service Container within a configurable timeout. 125 | func (p *Pal) HealthCheck(ctx context.Context) error { 126 | ctx, cancel := context.WithTimeout(ctx, p.config.HealthCheckTimeout) 127 | defer cancel() 128 | 129 | return p.container.HealthCheck(ctx) 130 | } 131 | 132 | // Init initializes Pal. Validates config, creates and initializes all singleton services. 133 | // If any error occurs during initialization, it will return it and 134 | // will not try to gracefully shutdown already initialized services. 135 | // Only first call is effective. 136 | func (p *Pal) Init(ctx context.Context) error { 137 | if !p.initialized.CompareAndSwap(false, true) { 138 | return nil 139 | } 140 | 141 | ctx = WithPal(ctx, p) 142 | 143 | if err := p.config.Validate(ctx); err != nil { 144 | return err 145 | } 146 | 147 | initCtx, cancel := context.WithTimeout(ctx, p.config.InitTimeout) 148 | defer cancel() 149 | 150 | if err := p.container.Init(initCtx); err != nil { 151 | return err 152 | } 153 | 154 | p.logger.Debug("Pal initialized") 155 | 156 | return nil 157 | } 158 | 159 | // Run eagerly starts runners, then blocks until: 160 | // - context is canceled 161 | // - one of the runners fails 162 | // - one of the given signals is received, if a signal is received again, the app will exit immediately without graceful shutdown 163 | // - all runners finish their work 164 | // Not goroutine safe, must only be called once. 165 | // After one of the events above occurs, the app will be gracefully shot down. 166 | // Errors returned from runners and during shutdown are collected and returned from Run(). 167 | func (p *Pal) Run(ctx context.Context, signals ...os.Signal) error { 168 | if len(signals) == 0 { 169 | signals = DefaultShutdownSignals 170 | } 171 | 172 | ctx = WithPal(ctx, p) 173 | 174 | ctx, stop := signal.NotifyContext(ctx, signals...) 175 | defer stop() 176 | 177 | if err := p.Init(ctx); err != nil { 178 | return err 179 | } 180 | 181 | go func() { 182 | <-ctx.Done() 183 | 184 | p.logger.Warn("Received signal, shutting down. Send it again to exit immediately") 185 | 186 | ctx, stop := signal.NotifyContext(context.Background(), signals...) 187 | defer stop() 188 | 189 | <-ctx.Done() 190 | p.logger.Error("Signal received again, exiting immediately") 191 | os.Exit(1) 192 | }() 193 | 194 | p.logger.Info("Running until signal is received or until job is done", "signals", signals) 195 | runErr := p.container.StartRunners(ctx) 196 | 197 | if errors.Is(runErr, context.Canceled) || errors.Is(runErr, ErrNoMainRunners) { 198 | runErr = nil 199 | } 200 | 201 | if runErr != nil { 202 | p.logger.Error("One or more runners failed, trying to shutdown gracefully", "error", runErr) 203 | } 204 | 205 | go func() { 206 | // a watchdog to make sure to forcefully exit if shutdown times out 207 | <-time.After(p.config.ShutdownTimeout) 208 | 209 | panic("shutdown timed out") 210 | }() 211 | 212 | shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(float64(p.config.ShutdownTimeout)*0.9)) 213 | shutdownCtx = WithPal(shutdownCtx, p) 214 | defer cancel() 215 | 216 | return errors.Join(runErr, p.container.Shutdown(shutdownCtx)) 217 | } 218 | 219 | // Services returns a map of all registered services in the container, keyed by their names. 220 | // This can be useful for debugging or introspection purposes. 221 | func (p *Pal) Services() map[string]ServiceDef { 222 | return p.container.Services() 223 | } 224 | 225 | // Invoke retrieves a service by name from the container. 226 | // It implements the Invoker interface. 227 | // The context is enriched with the Pal instance before being passed to the container. 228 | func (p *Pal) Invoke(ctx context.Context, name string, args ...any) (any, error) { 229 | ctx = WithPal(ctx, p) 230 | 231 | return p.container.Invoke(ctx, name, args...) 232 | } 233 | 234 | // InvokeByInterface retrieves a service by interface from the container. 235 | // It implements the Invoker interface. 236 | // The context is enriched with the Pal instance before being passed to the container. 237 | func (p *Pal) InvokeByInterface(ctx context.Context, iface reflect.Type, args ...any) (any, error) { 238 | ctx = WithPal(ctx, p) 239 | 240 | return p.container.InvokeByInterface(ctx, iface, args...) 241 | } 242 | 243 | // InjectInto injects services into the fields of the target struct. 244 | // It implements the Invoker interface. 245 | // The context is enriched with the Pal instance before being passed to the container. 246 | func (p *Pal) InjectInto(ctx context.Context, target any) error { 247 | ctx = WithPal(ctx, p) 248 | 249 | return p.container.InjectInto(ctx, target) 250 | } 251 | 252 | // Container returns the underlying Container instance. 253 | // This can be useful for advanced use cases where direct access to the container is needed. 254 | func (p *Pal) Container() *Container { 255 | return p.container 256 | } 257 | 258 | // Logger returns the logger instance used by Pal. 259 | // This can be useful for advanced use cases where direct access to the logger is needed. 260 | func (p *Pal) Logger() *slog.Logger { 261 | return p.logger 262 | } 263 | 264 | // Config returns a copy of pal's config. 265 | func (p *Pal) Config() Config { 266 | return *p.config 267 | } 268 | -------------------------------------------------------------------------------- /hook_priority_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/zhulik/pal" 10 | ) 11 | 12 | // HookPriorityTestService implements all lifecycle interfaces for testing 13 | type HookPriorityTestService struct { 14 | initCalled bool 15 | healthCheckCalled bool 16 | shutdownCalled bool 17 | runCalled bool 18 | initError error 19 | healthCheckError error 20 | shutdownError error 21 | runError error 22 | } 23 | 24 | func (h *HookPriorityTestService) Init(_ context.Context) error { 25 | h.initCalled = true 26 | return h.initError 27 | } 28 | 29 | func (h *HookPriorityTestService) HealthCheck(_ context.Context) error { 30 | h.healthCheckCalled = true 31 | return h.healthCheckError 32 | } 33 | 34 | func (h *HookPriorityTestService) Shutdown(_ context.Context) error { 35 | h.shutdownCalled = true 36 | return h.shutdownError 37 | } 38 | 39 | func (h *HookPriorityTestService) Run(_ context.Context) error { 40 | h.runCalled = true 41 | return h.runError 42 | } 43 | 44 | // TestHookPriority_ToInit tests that ToInit hook has priority over Init method 45 | func TestHookPriority_ToInit(t *testing.T) { 46 | t.Parallel() 47 | 48 | t.Run("ToInit hook is called instead of Init method", func(t *testing.T) { 49 | t.Parallel() 50 | 51 | service := &HookPriorityTestService{} 52 | 53 | hookCalled := false 54 | palService := pal.Provide(service). 55 | ToInit(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 56 | hookCalled = true 57 | return nil 58 | }) 59 | 60 | p := newPal(palService) 61 | 62 | err := p.Init(t.Context()) 63 | assert.NoError(t, err) 64 | 65 | // Verify hook was called 66 | assert.True(t, hookCalled, "ToInit hook should have been called") 67 | 68 | // Verify Init method was NOT called 69 | assert.False(t, service.initCalled, "Init method should not be called when ToInit hook is specified") 70 | }) 71 | 72 | t.Run("ToInit hook error is propagated", func(t *testing.T) { 73 | t.Parallel() 74 | 75 | expectedErr := errors.New("init hook error") 76 | service := &HookPriorityTestService{} 77 | 78 | palService := pal.Provide(service). 79 | ToInit(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 80 | return expectedErr 81 | }) 82 | 83 | p := newPal(palService) 84 | 85 | err := p.Init(t.Context()) 86 | assert.ErrorIs(t, err, expectedErr, "ToInit hook error should be propagated") 87 | }) 88 | 89 | t.Run("Init method is called when no ToInit hook is specified", func(t *testing.T) { 90 | t.Parallel() 91 | 92 | service := &HookPriorityTestService{} 93 | 94 | palService := pal.Provide(service) 95 | p := newPal(palService) 96 | 97 | err := p.Init(t.Context()) 98 | assert.NoError(t, err) 99 | 100 | // Verify Init method was called 101 | assert.True(t, service.initCalled, "Init method should be called when no ToInit hook is specified") 102 | }) 103 | } 104 | 105 | // TestHookPriority_ToHealthCheck tests that ToHealthCheck hook has priority over HealthCheck method 106 | func TestHookPriority_ToHealthCheck(t *testing.T) { 107 | t.Parallel() 108 | 109 | t.Run("ToHealthCheck hook is called instead of HealthCheck method", func(t *testing.T) { 110 | t.Parallel() 111 | 112 | service := &HookPriorityTestService{} 113 | 114 | hookCalled := false 115 | palService := pal.Provide(service). 116 | ToHealthCheck(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 117 | hookCalled = true 118 | return nil 119 | }) 120 | 121 | p := newPal(palService) 122 | ctx := pal.WithPal(t.Context(), p) 123 | 124 | // Initialize first 125 | err := p.Init(t.Context()) 126 | assert.NoError(t, err) 127 | 128 | // Perform health check 129 | err = p.HealthCheck(ctx) 130 | assert.NoError(t, err) 131 | 132 | // Verify hook was called 133 | assert.True(t, hookCalled, "ToHealthCheck hook should have been called") 134 | 135 | // Verify HealthCheck method was NOT called 136 | assert.False(t, service.healthCheckCalled, "HealthCheck method should not be called when ToHealthCheck hook is specified") 137 | }) 138 | 139 | t.Run("ToHealthCheck hook error is propagated", func(t *testing.T) { 140 | t.Parallel() 141 | 142 | expectedErr := errors.New("health check hook error") 143 | service := &HookPriorityTestService{} 144 | 145 | palService := pal.Provide(service). 146 | ToHealthCheck(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 147 | return expectedErr 148 | }) 149 | 150 | p := newPal(palService) 151 | 152 | // Initialize first 153 | err := p.Init(t.Context()) 154 | assert.NoError(t, err) 155 | 156 | // Perform health check 157 | err = p.HealthCheck(t.Context()) 158 | assert.ErrorIs(t, err, expectedErr, "ToHealthCheck hook error should be propagated") 159 | }) 160 | 161 | t.Run("HealthCheck method is called when no ToHealthCheck hook is specified", func(t *testing.T) { 162 | t.Parallel() 163 | 164 | service := &HookPriorityTestService{} 165 | 166 | palService := pal.Provide(service) 167 | p := newPal(palService) 168 | 169 | // Initialize first 170 | err := p.Init(t.Context()) 171 | assert.NoError(t, err) 172 | 173 | // Perform health check 174 | err = p.HealthCheck(t.Context()) 175 | assert.NoError(t, err) 176 | 177 | // Verify HealthCheck method was called 178 | assert.True(t, service.healthCheckCalled, "HealthCheck method should be called when no ToHealthCheck hook is specified") 179 | }) 180 | } 181 | 182 | // TestHookPriority_ToShutdown tests that ToShutdown hook has priority over Shutdown method 183 | func TestHookPriority_ToShutdown(t *testing.T) { 184 | t.Parallel() 185 | 186 | t.Run("ToShutdown hook is called instead of Shutdown method", func(t *testing.T) { 187 | t.Parallel() 188 | 189 | service := &HookPriorityTestService{} 190 | 191 | hookCalled := false 192 | palService := pal.Provide(service). 193 | ToShutdown(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 194 | hookCalled = true 195 | return nil 196 | }) 197 | 198 | p := newPal(palService) 199 | 200 | // Initialize first 201 | err := p.Init(t.Context()) 202 | assert.NoError(t, err) 203 | 204 | err = p.Run(t.Context()) 205 | assert.NoError(t, err) 206 | 207 | // Verify hook was called 208 | assert.True(t, hookCalled, "ToShutdown hook should have been called") 209 | 210 | // Verify Shutdown method was NOT called 211 | assert.False(t, service.shutdownCalled, "Shutdown method should not be called when ToShutdown hook is specified") 212 | }) 213 | 214 | t.Run("ToShutdown hook error is propagated", func(t *testing.T) { 215 | t.Parallel() 216 | 217 | expectedErr := errors.New("shutdown hook error") 218 | service := &HookPriorityTestService{} 219 | 220 | palService := pal.Provide(service). 221 | ToShutdown(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 222 | return expectedErr 223 | }) 224 | 225 | p := newPal(palService) 226 | 227 | // Initialize first 228 | err := p.Init(t.Context()) 229 | assert.NoError(t, err) 230 | 231 | err = p.Run(t.Context()) 232 | assert.ErrorIs(t, err, expectedErr, "ToShutdown hook error should be propagated") 233 | }) 234 | 235 | t.Run("Shutdown method is called when no ToShutdown hook is specified", func(t *testing.T) { 236 | t.Parallel() 237 | 238 | service := &HookPriorityTestService{} 239 | 240 | palService := pal.Provide(service) 241 | p := newPal(palService) 242 | 243 | // Initialize first 244 | err := p.Init(t.Context()) 245 | assert.NoError(t, err) 246 | 247 | err = p.Run(t.Context()) 248 | assert.NoError(t, err) 249 | 250 | // Verify Shutdown method was called 251 | assert.True(t, service.shutdownCalled, "Shutdown method should be called when no ToShutdown hook is specified") 252 | }) 253 | } 254 | 255 | // TestHookPriority_MultipleHooks tests that multiple hooks can be used together 256 | func TestHookPriority_MultipleHooks(t *testing.T) { 257 | t.Parallel() 258 | 259 | t.Run("all hooks are called and methods are not", func(t *testing.T) { 260 | t.Parallel() 261 | 262 | service := &HookPriorityTestService{} 263 | 264 | initHookCalled := false 265 | healthCheckHookCalled := false 266 | shutdownHookCalled := false 267 | 268 | palService := pal.Provide(service). 269 | ToInit(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 270 | initHookCalled = true 271 | return nil 272 | }). 273 | ToHealthCheck(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 274 | healthCheckHookCalled = true 275 | return nil 276 | }). 277 | ToShutdown(func(_ context.Context, _ *HookPriorityTestService, _ *pal.Pal) error { 278 | shutdownHookCalled = true 279 | return nil 280 | }) 281 | 282 | p := newPal(palService) 283 | ctx := pal.WithPal(t.Context(), p) 284 | 285 | // Initialize 286 | err := p.Init(t.Context()) 287 | assert.NoError(t, err) 288 | assert.True(t, initHookCalled, "ToInit hook should have been called") 289 | 290 | // Health check 291 | err = p.HealthCheck(ctx) 292 | assert.NoError(t, err) 293 | assert.True(t, healthCheckHookCalled, "ToHealthCheck hook should have been called") 294 | 295 | err = p.Run(t.Context()) 296 | assert.NoError(t, err) 297 | assert.True(t, shutdownHookCalled, "ToShutdown hook should have been called") 298 | 299 | // Verify none of the interface methods were called 300 | assert.False(t, service.initCalled, "Init method should not be called when ToInit hook is specified") 301 | assert.False(t, service.healthCheckCalled, "HealthCheck method should not be called when ToHealthCheck hook is specified") 302 | assert.False(t, service.shutdownCalled, "Shutdown method should not be called when ToShutdown hook is specified") 303 | }) 304 | } 305 | -------------------------------------------------------------------------------- /pal_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "syscall" 6 | "testing" 7 | "time" 8 | 9 | "github.com/stretchr/testify/require" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/mock" 13 | "github.com/zhulik/pal" 14 | ) 15 | 16 | // TestPal_New tests the New function 17 | func Test_New(t *testing.T) { 18 | t.Parallel() 19 | 20 | t.Run("creates a new Pal instance with no services", func(t *testing.T) { 21 | t.Parallel() 22 | 23 | p := newPal() 24 | 25 | assert.NotNil(t, p) 26 | assert.Contains(t, p.Services(), "*github.com/zhulik/pal.Pal") 27 | }) 28 | 29 | t.Run("creates a new Pal instance with services", func(t *testing.T) { 30 | t.Parallel() 31 | 32 | p := newPal( 33 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 34 | s := NewMockTestServiceStruct(t) 35 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 36 | return s, nil 37 | }), 38 | ) 39 | 40 | p = newPal(pal.ProvidePal(p)) 41 | 42 | assert.NoError(t, p.Init(t.Context())) 43 | assert.Contains(t, p.Services(), "*github.com/zhulik/pal_test.TestServiceStruct") 44 | }) 45 | 46 | t.Run("correctly initializes service lists", func(t *testing.T) { 47 | t.Parallel() 48 | 49 | p := newPal( 50 | pal.ProvideList( 51 | pal.ProvideList( 52 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 53 | s := NewMockTestServiceStruct(t) 54 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 55 | return s, nil 56 | }), 57 | ), 58 | ), 59 | ) 60 | 61 | require.NoError(t, p.Init(t.Context())) 62 | }) 63 | } 64 | 65 | // TestPal_FromContext tests the FromContext function 66 | func Test_FromContext(t *testing.T) { 67 | t.Parallel() 68 | 69 | t.Run("retrieves Pal from context", func(t *testing.T) { 70 | t.Parallel() 71 | 72 | p := newPal() 73 | ctx := pal.WithPal(t.Context(), p) 74 | 75 | result, err := pal.FromContext(ctx) 76 | assert.NoError(t, err) 77 | 78 | assert.Same(t, p, result) 79 | }) 80 | } 81 | 82 | // TestPal_InitTimeout tests the InitTimeout method 83 | func TestPal_InitTimeout(t *testing.T) { 84 | t.Parallel() 85 | 86 | t.Run("sets the init timeout", func(t *testing.T) { 87 | t.Parallel() 88 | 89 | p := newPal() 90 | timeout := 5 * time.Second 91 | 92 | result := p.InitTimeout(timeout) 93 | 94 | assert.Same(t, p, result) // Method should return the Pal instance for chaining 95 | }) 96 | } 97 | 98 | // TestPal_HealthCheckTimeout tests the HealthCheckTimeout method 99 | func TestPal_HealthCheckTimeout(t *testing.T) { 100 | t.Parallel() 101 | 102 | t.Run("sets the health check timeout", func(t *testing.T) { 103 | t.Parallel() 104 | 105 | p := newPal() 106 | timeout := 5 * time.Second 107 | 108 | result := p.HealthCheckTimeout(timeout) 109 | 110 | assert.Same(t, p, result) // Method should return the Pal instance for chaining 111 | }) 112 | } 113 | 114 | // TestPal_ShutdownTimeout tests the ShutdownTimeout method 115 | func TestPal_ShutdownTimeout(t *testing.T) { 116 | t.Parallel() 117 | 118 | t.Run("sets the shutdown timeout", func(t *testing.T) { 119 | t.Parallel() 120 | 121 | p := newPal() 122 | timeout := 5 * time.Second 123 | 124 | result := p.ShutdownTimeout(timeout) 125 | 126 | assert.Same(t, p, result) // Method should return the Pal instance for chaining 127 | }) 128 | } 129 | 130 | // TestPal_HealthCheck tests the HealthCheck method 131 | func TestPal_HealthCheck(t *testing.T) { 132 | t.Parallel() 133 | 134 | t.Run("performs health check on all services", func(t *testing.T) { 135 | t.Parallel() 136 | 137 | // Create a service that implements HealthChecker 138 | service := pal.Provide(NewMockTestServiceStruct(t)) 139 | p := newPal(service) 140 | 141 | err := p.HealthCheck(t.Context()) 142 | 143 | assert.NoError(t, err) 144 | }) 145 | 146 | // TODO: health check times out 147 | } 148 | 149 | // TestPal_Services tests the Services method 150 | func TestPal_Services(t *testing.T) { 151 | t.Parallel() 152 | 153 | t.Run("returns all services", func(t *testing.T) { 154 | t.Parallel() 155 | 156 | service := pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 157 | s := NewMockTestServiceStruct(t) 158 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 159 | return s, nil 160 | }) 161 | 162 | p := newPal(service) 163 | 164 | assert.NoError(t, p.Init(t.Context())) 165 | 166 | services := p.Services() 167 | 168 | assert.Contains(t, services, "*github.com/zhulik/pal_test.TestServiceStruct") 169 | assert.Contains(t, services, "*github.com/zhulik/pal.Pal") 170 | }) 171 | 172 | t.Run("returns a slice with only pal for no services", func(t *testing.T) { 173 | t.Parallel() 174 | 175 | p := newPal() 176 | assert.NoError(t, p.Init(t.Context())) 177 | 178 | services := p.Services() 179 | 180 | assert.Contains(t, services, "*github.com/zhulik/pal.Pal") 181 | }) 182 | } 183 | 184 | // TestPal_Invoke tests the Invoke method 185 | func TestPal_Invoke(t *testing.T) { 186 | t.Parallel() 187 | 188 | t.Run("invokes a service successfully", func(t *testing.T) { 189 | t.Parallel() 190 | 191 | p := newPal( 192 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 193 | s := NewMockTestServiceStruct(t) 194 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 195 | return s, nil 196 | }), 197 | ) 198 | 199 | assert.NoError(t, p.Init(t.Context())) 200 | 201 | instance, err := p.Invoke(t.Context(), "*github.com/zhulik/pal_test.TestServiceStruct") 202 | assert.NoError(t, err) 203 | assert.NotNil(t, instance) 204 | }) 205 | 206 | t.Run("returns error when service not found", func(t *testing.T) { 207 | t.Parallel() 208 | 209 | p := newPal() 210 | 211 | _, err := p.Invoke(t.Context(), "nonexistent") 212 | 213 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 214 | }) 215 | } 216 | 217 | // TestPal_Invoke tests the Run method 218 | func TestPal_Run(t *testing.T) { 219 | t.Parallel() 220 | 221 | t.Run("exists immediately when no runners given", func(t *testing.T) { 222 | t.Parallel() 223 | 224 | err := newPal(). 225 | InitTimeout(3*time.Second). 226 | HealthCheckTimeout(1*time.Second). 227 | ShutdownTimeout(3*time.Second). 228 | Run(t.Context(), syscall.SIGINT) 229 | 230 | assert.NoError(t, err) 231 | }) 232 | 233 | t.Run("exists after runners exist", func(t *testing.T) { 234 | t.Parallel() 235 | 236 | service := pal.ProvideFn[*RunnerServiceStruct](func(_ context.Context) (*RunnerServiceStruct, error) { 237 | s := NewMockRunnerServiceStruct(t) 238 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 239 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 240 | return s, nil 241 | }) 242 | 243 | err := newPal( 244 | service, 245 | ). 246 | InitTimeout(3*time.Second). 247 | HealthCheckTimeout(1*time.Second). 248 | ShutdownTimeout(3*time.Second). 249 | Run(t.Context(), syscall.SIGINT) 250 | 251 | require.NoError(t, err) 252 | 253 | runner, err := service.Instance(t.Context()) 254 | assert.NoError(t, err) 255 | assert.NotNil(t, runner) 256 | }) 257 | 258 | t.Run("errors during init - services are gracefully shut down", func(t *testing.T) { 259 | t.Parallel() 260 | 261 | // Create a service that will be initialized successfully 262 | shutdownService := pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 263 | s := NewMockTestServiceStruct(t) 264 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 265 | s.MockShutdowner.EXPECT().Shutdown(ctx).Return(nil) 266 | return s, nil 267 | }) 268 | 269 | // Create a service that will fail during initialization 270 | failingService := pal.Provide(NewMockTestServiceStruct(t)). 271 | ToInit(func(_ context.Context, _ *TestServiceStruct, _ *pal.Pal) error { 272 | return errTest 273 | }) 274 | 275 | // Create a runner that should not be started 276 | runnerService := pal.Provide(NewMockRunnerServiceStruct(t)) 277 | 278 | // Run the application - this should fail because failingService fails to initialize 279 | err := newPal( 280 | shutdownService, 281 | failingService, 282 | runnerService, 283 | ). 284 | InitTimeout(3*time.Second). 285 | HealthCheckTimeout(1*time.Second). 286 | ShutdownTimeout(3*time.Second). 287 | Run(t.Context(), syscall.SIGINT) 288 | 289 | // Verify that Run returns an error 290 | require.Error(t, err) 291 | assert.ErrorIs(t, err, errTest) 292 | }) 293 | 294 | t.Run("runners returning errors - services are gracefully shut down", func(t *testing.T) { 295 | t.Parallel() 296 | 297 | // Create a service that will track if it was shut down 298 | shutdownService := pal.ProvideFn[*TestServiceStruct](func(context.Context) (*TestServiceStruct, error) { 299 | s := NewMockTestServiceStruct(t) 300 | s.MockIniter.EXPECT().Init(mock.Anything).Return(nil) 301 | s.MockShutdowner.EXPECT().Shutdown(mock.Anything).Return(nil) 302 | return s, nil 303 | }) 304 | 305 | // Create a runner that will return an error 306 | errorRunnerService := pal.ProvideFn[MainRunner](func(_ context.Context) (*RunnerServiceStruct, error) { 307 | s := NewMockRunnerServiceStruct(t) 308 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 309 | s.MockRunner.EXPECT().Run(mock.Anything).Return(errTest) 310 | return s, nil 311 | }) 312 | 313 | // Create a normal runner 314 | runnerService := pal.ProvideFn[*RunnerServiceStruct](func(_ context.Context) (*RunnerServiceStruct, error) { 315 | s := NewMockRunnerServiceStruct(t) 316 | s.MockRunConfiger.EXPECT().RunConfig().Return(&pal.RunConfig{Wait: true}) 317 | s.MockRunner.EXPECT().Run(mock.Anything).Return(nil) 318 | return s, nil 319 | }) 320 | 321 | // Run the application - this should fail because errorRunnerService returns an error 322 | err := newPal( 323 | shutdownService, 324 | errorRunnerService, 325 | runnerService, 326 | ). 327 | InitTimeout(3*time.Second). 328 | HealthCheckTimeout(1*time.Second). 329 | ShutdownTimeout(3*time.Second). 330 | Run(t.Context(), syscall.SIGINT) 331 | 332 | // Verify that Run returns an error 333 | assert.ErrorIs(t, err, errTest) 334 | }) 335 | } 336 | -------------------------------------------------------------------------------- /container.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "maps" 9 | "reflect" 10 | "slices" 11 | 12 | typetostring "github.com/samber/go-type-to-string" 13 | 14 | "golang.org/x/sync/errgroup" 15 | 16 | "github.com/zhulik/pal/pkg/dag" 17 | ) 18 | 19 | type factoryServiceMaping struct { 20 | Factory any 21 | Service ServiceDef 22 | } 23 | 24 | type factoryService interface { 25 | Factory() any 26 | MustFactory() any 27 | } 28 | 29 | // Container is responsible for storing services, instances and the dependency graph 30 | type Container struct { 31 | pal *Pal 32 | 33 | services map[string]ServiceDef 34 | factories map[string]factoryServiceMaping 35 | graph *dag.DAG[string, ServiceDef] 36 | logger *slog.Logger 37 | } 38 | 39 | // NewContainer creates a new Container instance 40 | func NewContainer(pal *Pal, services ...ServiceDef) *Container { 41 | services = flattenServices(services) 42 | 43 | container := &Container{ 44 | pal: pal, 45 | services: map[string]ServiceDef{}, 46 | factories: map[string]factoryServiceMaping{}, 47 | graph: dag.New[string, ServiceDef](), 48 | logger: slog.With("palComponent", "Container"), 49 | } 50 | 51 | for _, service := range services { 52 | container.addService(service) 53 | if factory, ok := service.(factoryService); ok { 54 | // Add Factory to the container 55 | fn := factory.Factory() 56 | fnType := reflect.TypeOf(fn) 57 | container.factories[typetostring.GetReflectType(fnType)] = factoryServiceMaping{ 58 | Factory: fn, 59 | Service: service, 60 | } 61 | 62 | // Add MustFactory to the container 63 | fn = factory.MustFactory() 64 | fnType = reflect.TypeOf(fn) 65 | container.factories[typetostring.GetReflectType(fnType)] = factoryServiceMaping{ 66 | Factory: fn, 67 | Service: service, 68 | } 69 | } 70 | } 71 | 72 | return container 73 | } 74 | 75 | func (c *Container) Init(ctx context.Context) error { 76 | c.logger.Debug("Building dependency tree...") 77 | 78 | for _, service := range c.services { 79 | if err := c.addDependencyVertex(service, nil); err != nil { 80 | return err 81 | } 82 | } 83 | 84 | for _, service := range c.graph.ReverseTopologicalOrder() { 85 | if err := service.Init(ctx); err != nil { 86 | c.logger.Error("Failed to initialize container", "error", err) 87 | return err 88 | } 89 | } 90 | 91 | c.logger.Debug("Container initialized") 92 | return nil 93 | } 94 | 95 | func (c *Container) Invoke(ctx context.Context, name string, args ...any) (any, error) { 96 | service, ok := c.services[name] 97 | if !ok { 98 | return nil, fmt.Errorf("%w: '%s', known services: %s", ErrServiceNotFound, name, c.services) 99 | } 100 | 101 | if len(args) != service.Arguments() { 102 | return nil, fmt.Errorf("%w: '%s': %d arguments expected, got %d", ErrServiceInvalidArgumentsCount, name, service.Arguments(), len(args)) 103 | } 104 | 105 | instance, err := service.Instance(ctx, args...) 106 | if err != nil { 107 | return nil, fmt.Errorf("%w: '%s': %w", ErrServiceInitFailed, name, err) 108 | } 109 | 110 | return instance, nil 111 | } 112 | 113 | func (c *Container) InvokeByInterface(ctx context.Context, iface reflect.Type, args ...any) (any, error) { 114 | if iface.Kind() != reflect.Interface { 115 | return nil, fmt.Errorf("%w: must be an interface, got %s", ErrNotAnInterface, iface.String()) 116 | } 117 | 118 | matches := []ServiceDef{} 119 | for _, service := range c.services { 120 | instance := service.Make() 121 | if instance == nil { 122 | continue 123 | } 124 | if reflect.TypeOf(instance).Implements(iface) { 125 | matches = append(matches, service) 126 | } 127 | } 128 | if len(matches) == 0 { 129 | return nil, fmt.Errorf("%w: no implementations of %s found", ErrServiceNotFound, iface.String()) 130 | } 131 | 132 | if len(matches) == 1 { 133 | return matches[0].Instance(ctx, args...) 134 | } 135 | 136 | return nil, fmt.Errorf("%w: found %d services for interface %s", ErrMultipleServicesFoundByInterface, len(matches), iface.String()) 137 | } 138 | 139 | func (c *Container) InjectInto(ctx context.Context, target any) error { 140 | v := reflect.ValueOf(target).Elem() 141 | t := v.Type() 142 | 143 | for i := 0; i < t.NumField(); i++ { 144 | field := v.Field(i) 145 | 146 | tags, err := ParseTag(t.Field(i).Tag.Get("pal")) 147 | if err != nil { 148 | return err 149 | } 150 | if _, ok := tags[TagSkip]; ok || !field.CanSet() { 151 | continue 152 | } 153 | 154 | fieldType := t.Field(i).Type 155 | 156 | if fieldType == reflect.TypeOf((*slog.Logger)(nil)) && c.pal.config.AttrSetters != nil { 157 | c.injectLoggerIntoField(field, target) 158 | continue 159 | } 160 | 161 | if _, ok := tags[TagMatchInterface]; ok { 162 | err = c.injectByInterface(ctx, field, fieldType) 163 | if err != nil { 164 | return err 165 | } 166 | continue 167 | } 168 | 169 | typeName, mustInject := tags[TagName] 170 | 171 | if typeName == "" { 172 | typeName = typetostring.GetReflectType(fieldType) 173 | } 174 | 175 | if fieldType.Kind() == reflect.Func { 176 | mapping, ok := c.factories[typeName] 177 | if ok { 178 | field.Set(reflect.ValueOf(mapping.Factory)) 179 | } 180 | 181 | continue 182 | } 183 | 184 | err = c.injectByName(ctx, typeName, field) 185 | if err != nil { 186 | if errors.Is(err, ErrServiceNotFound) && !mustInject { 187 | continue 188 | } 189 | return err 190 | } 191 | } 192 | 193 | return nil 194 | } 195 | 196 | func (c *Container) injectByInterface(ctx context.Context, field reflect.Value, fieldType reflect.Type) error { 197 | dependency, err := c.InvokeByInterface(ctx, fieldType) 198 | if err != nil { 199 | return err 200 | } 201 | 202 | field.Set(reflect.ValueOf(dependency)) 203 | 204 | return nil 205 | } 206 | 207 | func (c *Container) injectByName(ctx context.Context, name string, field reflect.Value) error { 208 | dependency, err := c.Invoke(ctx, name) 209 | if err != nil { 210 | if errors.Is(err, ErrServiceInvalidArgumentsCount) { 211 | return fmt.Errorf("%w: '%s': %w", ErrFactoryServiceDependency, name, err) 212 | } 213 | return err 214 | } 215 | 216 | field.Set(reflect.ValueOf(dependency)) 217 | 218 | return nil 219 | } 220 | 221 | func (c *Container) Shutdown(ctx context.Context) error { 222 | c.logger.Debug("Shutting down all runners") 223 | 224 | for _, service := range c.graph.TopologicalOrder() { 225 | err := service.Shutdown(ctx) 226 | if err != nil { 227 | c.logger.Error("Failed to shutdown service. Exiting immediately", "service", service.Name(), "error", err) 228 | return err 229 | } 230 | } 231 | 232 | c.logger.Debug("Container shut down successfully") 233 | return nil 234 | } 235 | 236 | func (c *Container) HealthCheck(ctx context.Context) error { 237 | var wg errgroup.Group 238 | 239 | c.logger.Debug("Healthchecking services") 240 | 241 | for _, service := range c.graph.TopologicalOrder() { 242 | wg.Go(func() error { 243 | // Do not check pal again, this leads to recursion 244 | if service.Name() == "*github.com/zhulik/pal.Pal" { 245 | return nil 246 | } 247 | 248 | err := service.HealthCheck(ctx) 249 | if err != nil { 250 | return err 251 | } 252 | 253 | return nil 254 | }) 255 | } 256 | 257 | err := wg.Wait() 258 | if err != nil { 259 | c.logger.Error("Healthcheck failed", "error", err) 260 | return err 261 | } 262 | 263 | c.logger.Debug("Healthcheck successful") 264 | 265 | return nil 266 | } 267 | 268 | // Services returns a map of all registered services in the container, keyed by their names. 269 | // This can be useful for debugging or introspection purposes. 270 | func (c *Container) Services() map[string]ServiceDef { 271 | return c.services 272 | } 273 | 274 | // StartRunners starts all services that implement the Runner interface in background goroutines. 275 | // It creates a cancellable context that will be canceled during shutdown. 276 | // Returns an error if any runner fails, though runners continue to execute independently. 277 | func (c *Container) StartRunners(ctx context.Context) error { 278 | services := slices.Collect(maps.Values(c.services)) 279 | return RunServices(ctx, services) 280 | } 281 | 282 | // Graph returns the dependency graph of services. 283 | // This can be useful for visualization or analysis of the service dependencies. 284 | func (c *Container) Graph() *dag.DAG[string, ServiceDef] { 285 | return c.graph 286 | } 287 | 288 | func (c *Container) addService(service ServiceDef) { 289 | setPalField(reflect.ValueOf(service), c.pal, map[reflect.Value]bool{}) 290 | c.services[service.Name()] = service 291 | } 292 | 293 | // addDependencyVertex adds a service to the dependency graph and recursively adds its dependencies. 294 | // If parent is not nil, it also adds an edge from parent to service in the graph. 295 | // This method is used during container initialization to build the complete dependency graph. 296 | func (c *Container) addDependencyVertex(service ServiceDef, parent ServiceDef) error { 297 | c.graph.AddVertexIfNotExist(service.Name(), service) 298 | 299 | if parent != nil { 300 | if err := c.graph.AddEdgeIfNotExist(parent.Name(), service.Name()); err != nil { 301 | return err 302 | } 303 | } 304 | m := service.Make() 305 | if isNil(m) { 306 | return nil 307 | } 308 | val := reflect.ValueOf(m) 309 | if val.Kind() == reflect.Ptr { 310 | val = val.Elem() 311 | } 312 | 313 | if !val.IsValid() { 314 | return nil 315 | } 316 | 317 | typ := val.Type() 318 | for i := 0; i < typ.NumField(); i++ { 319 | field := typ.Field(i) 320 | 321 | if !field.IsExported() { 322 | continue 323 | } 324 | 325 | tags, err := ParseTag(field.Tag.Get("pal")) 326 | if err != nil { 327 | return err 328 | } 329 | 330 | dependencyName := tags[TagName] 331 | 332 | if dependencyName == "" { 333 | dependencyName = typetostring.GetReflectType(field.Type) 334 | } 335 | 336 | if childService, ok := c.services[dependencyName]; ok { 337 | if err := c.addDependencyVertex(childService, service); err != nil { 338 | return err 339 | } 340 | } 341 | 342 | if factoryMapping, ok := c.factories[dependencyName]; ok { 343 | if err := c.addDependencyVertex(factoryMapping.Service, service); err != nil { 344 | return err 345 | } 346 | } 347 | } 348 | 349 | return nil 350 | } 351 | 352 | func (c *Container) injectLoggerIntoField(field reflect.Value, target any) { 353 | logger := slog.Default() 354 | for _, attrSetter := range c.pal.config.AttrSetters { 355 | name, value := attrSetter(target) 356 | logger = logger.With(name, value) 357 | } 358 | field.Set(reflect.ValueOf(logger)) 359 | } 360 | 361 | func setPalField(v reflect.Value, pal *Pal, visited map[reflect.Value]bool) { 362 | if visited[v] { 363 | return 364 | } 365 | visited[v] = true 366 | 367 | if v.Kind() == reflect.Interface { 368 | v = v.Elem() 369 | } 370 | 371 | if v.Kind() == reflect.Pointer { 372 | v = v.Elem() 373 | } 374 | 375 | if v.Kind() != reflect.Struct { 376 | return 377 | } 378 | 379 | for i := 0; i < v.NumField(); i++ { 380 | field := v.Field(i) 381 | if field.CanSet() && field.Type() == reflect.TypeOf(pal) { 382 | field.Set(reflect.ValueOf(pal)) 383 | } 384 | 385 | if field.Kind() == reflect.Struct || (field.Kind() == reflect.Pointer && !field.IsNil()) { 386 | setPalField(field, pal, visited) 387 | } 388 | 389 | if field.Kind() == reflect.Array || field.Kind() == reflect.Slice { 390 | for i := 0; i < field.Len(); i++ { 391 | item := field.Index(i) 392 | setPalField(item, pal, visited) 393 | } 394 | } 395 | } 396 | } 397 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/brunoga/deep v1.2.4 h1:Aj9E9oUbE+ccbyh35VC/NHlzzjfIVU69BXu2mt2LmL8= 2 | github.com/brunoga/deep v1.2.4/go.mod h1:GDV6dnXqn80ezsLSZ5Wlv1PdKAWAO4L5PnKYtv2dgaI= 3 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 4 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 7 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= 9 | github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= 10 | github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= 11 | github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 12 | github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= 13 | github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= 14 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 15 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 16 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 17 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 18 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 19 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 20 | github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k= 21 | github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= 22 | github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= 23 | github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= 24 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 25 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 26 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 27 | github.com/google/licensecheck v0.3.1 h1:QoxgoDkaeC4nFrtGN1jV7IPmDCHFNIVh54e5hSt6sPs= 28 | github.com/google/licensecheck v0.3.1/go.mod h1:ORkR35t/JjW+emNKtfJDII0zlciG9JgbT7SmsohlHmY= 29 | github.com/google/safehtml v0.0.3-0.20211026203422-d6f0e11a5516 h1:pSEdbeokt55L2hwtWo6A2k7u5SG08rmw0LhWEyrdWgk= 30 | github.com/google/safehtml v0.0.3-0.20211026203422-d6f0e11a5516/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU= 31 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 32 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 33 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 34 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 35 | github.com/jedib0t/go-pretty/v6 v6.6.7 h1:m+LbHpm0aIAPLzLbMfn8dc3Ht8MW7lsSO4MPItz/Uuo= 36 | github.com/jedib0t/go-pretty/v6 v6.6.7/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU= 37 | github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= 38 | github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= 39 | github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w= 40 | github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY= 41 | github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0= 42 | github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak= 43 | github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w= 44 | github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI= 45 | github.com/knadh/koanf/providers/posflag v0.1.0 h1:mKJlLrKPcAP7Ootf4pBZWJ6J+4wHYujwipe7Ie3qW6U= 46 | github.com/knadh/koanf/providers/posflag v0.1.0/go.mod h1:SYg03v/t8ISBNrMBRMlojH8OsKowbkXV7giIbBVgbz0= 47 | github.com/knadh/koanf/providers/structs v0.1.0 h1:wJRteCNn1qvLtE5h8KQBvLJovidSdntfdyIbbCzEyE0= 48 | github.com/knadh/koanf/providers/structs v0.1.0/go.mod h1:sw2YZ3txUcqA3Z27gPlmmBzWn1h8Nt9O6EP/91MkcWE= 49 | github.com/knadh/koanf/v2 v2.2.1 h1:jaleChtw85y3UdBnI0wCqcg1sj1gPoz6D3caGNHtrNE= 50 | github.com/knadh/koanf/v2 v2.2.1/go.mod h1:PSFru3ufQgTsI7IF+95rf9s8XA1+aHxKuO/W+dPoHEY= 51 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 52 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 53 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 54 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 55 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 56 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 57 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 58 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 59 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 60 | github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 61 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 62 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 63 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 64 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 65 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 66 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 67 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 68 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 69 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 70 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 71 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 73 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 74 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 75 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 76 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 77 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 78 | github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 79 | github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= 80 | github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= 81 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 82 | github.com/samber/go-type-to-string v1.8.0 h1:5z6tDTjtXxkIAoAuHAZYMYR8mkBZjVgeSH7jcSLqc8w= 83 | github.com/samber/go-type-to-string v1.8.0/go.mod h1:jpU77vIDoIxkahknKDoEx9C8bQ1ADnh2sotZ8I4QqBU= 84 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 85 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 86 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 87 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 88 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 89 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 90 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 91 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 92 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 93 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 94 | github.com/vektra/mockery/v3 v3.5.4 h1:AqbLKhw+H3U5OBqEAcUilxRIcLwHfFKzTbLlyfEqx9o= 95 | github.com/vektra/mockery/v3 v3.5.4/go.mod h1:6rmlzyACJQig1UFoUYyLMS/O+2aGz6BgKAO9C8t9/v0= 96 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= 97 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= 98 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= 99 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= 100 | github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= 101 | github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= 102 | github.com/yuin/goldmark v1.6.0 h1:boZcn2GTjpsynOsC0iJHnBWa4Bi0qzfJjthwauItG68= 103 | github.com/yuin/goldmark v1.6.0/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 104 | golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= 105 | golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= 106 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 107 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 108 | golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= 109 | golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= 110 | golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= 111 | golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= 112 | golang.org/x/pkgsite v0.0.0-20250424231009-e863a039941f h1:2Lt9FSww7q8JZiB4U/C6txym5cwp+MiWCkpJWlQkp5w= 113 | golang.org/x/pkgsite v0.0.0-20250424231009-e863a039941f/go.mod h1:qapReMTMRfLR/uTV89hH/4YW9bcfUBsFLocl2PpzSmo= 114 | golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= 115 | golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 116 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 118 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= 120 | golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 121 | golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= 122 | golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= 123 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 124 | golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= 125 | golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= 126 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 127 | golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= 128 | golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= 129 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 130 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 131 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 132 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 133 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 134 | rsc.io/markdown v0.0.0-20231214224604-88bb533a6020 h1:GqQcl3Kno/rOntek8/d8axYjau8r/c1zVFojXS6WJFI= 135 | rsc.io/markdown v0.0.0-20231214224604-88bb533a6020/go.mod h1:8xcPgWmwlZONN1D9bjxtHEjrUtSEa3fakVF8iaewYKQ= 136 | -------------------------------------------------------------------------------- /api.go: -------------------------------------------------------------------------------- 1 | package pal 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "reflect" 7 | 8 | typetostring "github.com/samber/go-type-to-string" 9 | ) 10 | 11 | // Provide registers a const as a service. `T` is used to generating service name. 12 | // Typically, `T` would be one of: 13 | // - An interface, in this case passed value must implement it. Used when T may have multiple implementations like mocks for tests. 14 | // - A pointer to an instance of `T`. For instance,`Provide[*Foo](&Foo{})`. Used when mocking is not required. 15 | // If the passed value implements Initer, Init() will be called. 16 | func Provide[T any](value T) *ServiceConst[T] { 17 | validateNonNilPointer(value) 18 | 19 | return ProvideNamed(typetostring.GetType[T](), value) 20 | } 21 | 22 | // ProvideNamed registers a const as a service with a given name. Acts like Provide but allows to specify a name. 23 | func ProvideNamed[T any](name string, value T) *ServiceConst[T] { 24 | validateNonNilPointer(value) 25 | 26 | return &ServiceConst[T]{instance: value, ServiceTyped: ServiceTyped[T]{name: name}} 27 | } 28 | 29 | // ProvideFn registers a singleton built with a given function. 30 | func ProvideFn[I any, T any](fn func(ctx context.Context) (T, error)) *ServiceFnSingleton[I, T] { 31 | return ProvideNamedFn[I](typetostring.GetType[I](), fn) 32 | } 33 | 34 | // ProvideFn registers a singleton built with a given function. 35 | func ProvideNamedFn[I any, T any](name string, fn func(ctx context.Context) (T, error)) *ServiceFnSingleton[I, T] { 36 | validateFactoryFunction[I, T](fn) 37 | 38 | return &ServiceFnSingleton[I, T]{ 39 | fn: fn, 40 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 41 | } 42 | } 43 | 44 | // ProvideRunner turns the given function into an anounumous runner. It will run in the background, and the passed context will 45 | // be canceled on app shutdown. 46 | func ProvideRunner(fn func(ctx context.Context) error) *ServiceRunner { 47 | return &ServiceRunner{ 48 | fn: fn, 49 | } 50 | } 51 | 52 | // ProvideList registers a list of given services. 53 | func ProvideList(services ...ServiceDef) *ServiceList { 54 | return &ServiceList{Services: services} 55 | } 56 | 57 | // ProvideFactory0 registers a factory service that is build with a given function with no arguments. 58 | func ProvideFactory0[I any, T any](fn func(ctx context.Context) (T, error)) *ServiceFactory0[I, T] { 59 | return ProvideNamedFactory0[I](typetostring.GetType[I](), fn) 60 | } 61 | 62 | // ProvideFactory1 registers a factory service that is built in runtime with a given function that takes one argument. 63 | func ProvideFactory1[I any, T any, P1 any](fn func(ctx context.Context, p1 P1) (T, error)) *ServiceFactory1[I, T, P1] { 64 | validateFactoryFunction[I, T](fn) 65 | return ProvideNamedFactory1[I](typetostring.GetType[I](), fn) 66 | } 67 | 68 | // ProvideFactory2 registers a factory service that is built in runtime with a given function that takes two arguments. 69 | func ProvideFactory2[I any, T any, P1 any, P2 any](fn func(ctx context.Context, p1 P1, p2 P2) (T, error)) *ServiceFactory2[I, T, P1, P2] { 70 | return ProvideNamedFactory2[I](typetostring.GetType[I](), fn) 71 | } 72 | 73 | // ProvideFactory3 registers a factory service that is built in runtime with a given function that takes three arguments. 74 | func ProvideFactory3[I any, T any, P1 any, P2 any, P3 any](fn func(ctx context.Context, p1 P1, p2 P2, p3 P3) (T, error)) *ServiceFactory3[I, T, P1, P2, P3] { 75 | return ProvideNamedFactory3[I](typetostring.GetType[I](), fn) 76 | } 77 | 78 | // ProvideFactory4 registers a factory service that is built in runtime with a given function that takes four arguments. 79 | func ProvideFactory4[I any, T any, P1 any, P2 any, P3 any, P4 any](fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) (T, error)) *ServiceFactory4[I, T, P1, P2, P3, P4] { 80 | return ProvideNamedFactory4[I](typetostring.GetType[I](), fn) 81 | } 82 | 83 | // ProvideFactory5 registers a factory service that is built in runtime with a given function that takes five arguments. 84 | func ProvideFactory5[I any, T any, P1 any, P2 any, P3 any, P4 any, P5 any](fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) (T, error)) *ServiceFactory5[I, T, P1, P2, P3, P4, P5] { 85 | return ProvideNamedFactory5[I](typetostring.GetType[I](), fn) 86 | } 87 | 88 | // ProvideNamedFactory0 is like ProvideFactory0 but allows to specify a name. 89 | func ProvideNamedFactory0[I any, T any](name string, fn func(ctx context.Context) (T, error)) *ServiceFactory0[I, T] { 90 | validateFactoryFunction[I, T](fn) 91 | return &ServiceFactory0[I, T]{ 92 | fn: fn, 93 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 94 | } 95 | } 96 | 97 | // ProvideNamedFactory1 is like ProvideFactory1 but allows to specify a name. 98 | func ProvideNamedFactory1[I any, T any, P1 any](name string, fn func(ctx context.Context, p1 P1) (T, error)) *ServiceFactory1[I, T, P1] { 99 | validateFactoryFunction[I, T](fn) 100 | 101 | return &ServiceFactory1[I, T, P1]{ 102 | fn: fn, 103 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 104 | } 105 | } 106 | 107 | // ProvideNamedFactory2 is like ProvideFactory2 but allows to specify a name. 108 | func ProvideNamedFactory2[I any, T any, P1 any, P2 any](name string, fn func(ctx context.Context, p1 P1, p2 P2) (T, error)) *ServiceFactory2[I, T, P1, P2] { 109 | validateFactoryFunction[I, T](fn) 110 | 111 | return &ServiceFactory2[I, T, P1, P2]{ 112 | fn: fn, 113 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 114 | } 115 | } 116 | 117 | // ProvideNamedFactory3 is like ProvideFactory3 but allows to specify a name. 118 | func ProvideNamedFactory3[I any, T any, P1 any, P2 any, P3 any](name string, fn func(ctx context.Context, p1 P1, p2 P2, p3 P3) (T, error)) *ServiceFactory3[I, T, P1, P2, P3] { 119 | validateFactoryFunction[I, T](fn) 120 | 121 | return &ServiceFactory3[I, T, P1, P2, P3]{ 122 | fn: fn, 123 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 124 | } 125 | } 126 | 127 | // ProvideNamedFactory4 is like ProvideFactory4 but allows to specify a name. 128 | func ProvideNamedFactory4[I any, T any, P1 any, P2 any, P3 any, P4 any](name string, fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4) (T, error)) *ServiceFactory4[I, T, P1, P2, P3, P4] { 129 | validateFactoryFunction[I, T](fn) 130 | 131 | return &ServiceFactory4[I, T, P1, P2, P3, P4]{ 132 | fn: fn, 133 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 134 | } 135 | } 136 | 137 | // ProvideNamedFactory5 is like ProvideFactory5 but allows to specify a name. 138 | func ProvideNamedFactory5[I any, T any, P1 any, P2 any, P3 any, P4 any, P5 any](name string, fn func(ctx context.Context, p1 P1, p2 P2, p3 P3, p4 P4, p5 P5) (T, error)) *ServiceFactory5[I, T, P1, P2, P3, P4, P5] { 139 | validateFactoryFunction[I, T](fn) 140 | 141 | return &ServiceFactory5[I, T, P1, P2, P3, P4, P5]{ 142 | fn: fn, 143 | ServiceFactory: ServiceFactory[I, T]{ServiceTyped: ServiceTyped[I]{name: name}}, 144 | } 145 | } 146 | 147 | // ProvidePal registers all services for the given pal instance 148 | func ProvidePal(pal *Pal) *ServiceList { 149 | services := make([]ServiceDef, 0, len(pal.Services())) 150 | for _, v := range pal.Services() { 151 | if v.Name() != "*github.com/zhulik/pal.Pal" { 152 | services = append(services, v) 153 | } 154 | } 155 | 156 | return ProvideList(services...) 157 | } 158 | 159 | // Invoke retrieves or creates an instance of type T from the given Pal container. 160 | // Invoker may be nil, in this case an instance of Pal will be extracted from the context, 161 | // if the context does not contain a Pal instance, an error will be returned. 162 | func Invoke[T any](ctx context.Context, invoker Invoker, args ...any) (T, error) { 163 | name := typetostring.GetType[T]() 164 | return InvokeNamed[T](ctx, invoker, name, args...) 165 | } 166 | 167 | // MustInvoke is like Invoke but panics if an error occurs. 168 | func MustInvoke[T any](ctx context.Context, invoker Invoker, args ...any) T { 169 | return must(Invoke[T](ctx, invoker, args...)) 170 | } 171 | 172 | // InvokeNamed is like Invoke but allows to specify a name. 173 | func InvokeNamed[T any](ctx context.Context, invoker Invoker, name string, args ...any) (T, error) { 174 | if invoker == nil { 175 | var err error 176 | invoker, err = FromContext(ctx) 177 | if err != nil { 178 | return empty[T](), err 179 | } 180 | } 181 | 182 | a, err := invoker.Invoke(ctx, name, args...) 183 | if err != nil { 184 | return empty[T](), err 185 | } 186 | 187 | casted, ok := a.(T) 188 | if !ok { 189 | return empty[T](), fmt.Errorf("%w: %s. %+v does not implement %s", ErrServiceInvalid, name, a, name) 190 | } 191 | 192 | return casted, nil 193 | } 194 | 195 | // MustInvokeNamed is like InvokeNamed but panics if an error occurs. 196 | func MustInvokeNamed[T any](ctx context.Context, invoker Invoker, name string, args ...any) T { 197 | return must(InvokeNamed[T](ctx, invoker, name, args...)) 198 | } 199 | 200 | // InvokeAs invokes a service and casts it to the expected type. It returns an error if the cast fails. 201 | // May be useful when invoking a service with an interface type and you want to cast it to a concrete type. 202 | // Invoker may be nil, in this case an instance of Pal will be extracted from the context, 203 | // if the context does not contain a Pal instance, an error will be returned. 204 | func InvokeAs[T any, C any](ctx context.Context, invoker Invoker, args ...any) (*C, error) { 205 | name := typetostring.GetType[T]() 206 | return InvokeNamedAs[T, C](ctx, invoker, name, args...) 207 | } 208 | 209 | // InvokeNamedAs is like InvokeAs but allows to specify a name. 210 | func InvokeNamedAs[T any, C any](ctx context.Context, invoker Invoker, name string, args ...any) (*C, error) { 211 | service, err := InvokeNamed[T](ctx, invoker, name, args...) 212 | if err != nil { 213 | return nil, err 214 | } 215 | casted, ok := any(service).(*C) 216 | if !ok { 217 | var c *C 218 | return nil, fmt.Errorf("%w: %T cannot be cast to %T", ErrServiceInvalidCast, service, c) 219 | } 220 | 221 | return casted, nil 222 | } 223 | 224 | // MustInvokeNamedAs is like InvokeNamedAs but panics if an error occurs. 225 | func MustInvokeNamedAs[T any, C any](ctx context.Context, invoker Invoker, name string, args ...any) *C { 226 | return must(InvokeNamedAs[T, C](ctx, invoker, name, args...)) 227 | } 228 | 229 | // MustInvokeAs is like InvokeAs but panics if an error occurs. 230 | func MustInvokeAs[T any, C any](ctx context.Context, invoker Invoker, args ...any) *C { 231 | return must(InvokeAs[T, C](ctx, invoker, args...)) 232 | } 233 | 234 | // InvokeByInterface invokes a service by interface. 235 | // It iterates over all services and returns the only one that implements the interface. 236 | // If no service implements the interface, or multiple services implement the interface, or given I is not an interface 237 | // an error will be returned. 238 | // Invoker may be nil, in this case an instance of Pal will be extracted from the context, 239 | // if the context does not contain a Pal instance, an error will be returned. 240 | func InvokeByInterface[I any](ctx context.Context, invoker Invoker, args ...any) (I, error) { 241 | if invoker == nil { 242 | var err error 243 | invoker, err = FromContext(ctx) 244 | if err != nil { 245 | return empty[I](), err 246 | } 247 | } 248 | iface := reflect.TypeOf((*I)(nil)).Elem() 249 | if invoker == nil { 250 | var err error 251 | invoker, err = FromContext(ctx) 252 | if err != nil { 253 | return empty[I](), err 254 | } 255 | } 256 | 257 | instance, err := invoker.InvokeByInterface(ctx, iface, args...) 258 | if err != nil { 259 | return empty[I](), err 260 | } 261 | return instance.(I), nil 262 | } 263 | 264 | // MustInvokeByInterface is like InvokeByInterface but panics if an error occurs. 265 | func MustInvokeByInterface[I any](ctx context.Context, invoker Invoker, args ...any) I { 266 | return must(InvokeByInterface[I](ctx, invoker, args...)) 267 | } 268 | 269 | // Build resolves dependencies for a struct of type T using the provided context and Invoker. 270 | // It initializes the struct's fields by injecting appropriate dependencies based on the field types. 271 | // Returns the fully initialized struct or an error if dependency resolution fails. 272 | // Invoker may be nil, in this case an instance of Pal will be extracted from the context, 273 | // if the context does not contain a Pal instance, an error will be returned. 274 | func Build[T any](ctx context.Context, invoker Invoker) (*T, error) { 275 | s := new(T) 276 | 277 | err := InjectInto(ctx, invoker, s) 278 | if err != nil { 279 | return nil, err 280 | } 281 | 282 | return s, nil 283 | } 284 | 285 | // MustBuild is like Build but panics if an error occurs.x 286 | func MustBuild[T any](ctx context.Context, invoker Invoker) *T { 287 | return must(Build[T](ctx, invoker)) 288 | } 289 | 290 | // InjectInto populates the fields of a struct of type T with dependencies obtained from the given Invoker. 291 | // It only sets fields that are exported and match a resolvable dependency, skipping fields when ErrServiceNotFound occurs. 292 | // Returns an error if dependency invocation fails or other unrecoverable errors occur during injection. 293 | func InjectInto[T any](ctx context.Context, invoker Invoker, s *T) error { 294 | if invoker == nil { 295 | var err error 296 | invoker, err = FromContext(ctx) 297 | if err != nil { 298 | return err 299 | } 300 | } 301 | return invoker.InjectInto(ctx, s) 302 | } 303 | 304 | // MustInjectInto is like InjectInto but panics if an error occurs. 305 | func MustInjectInto[T any](ctx context.Context, invoker Invoker, s *T) { 306 | must("", InjectInto(ctx, invoker, s)) 307 | } 308 | 309 | func must[T any](value T, err error) T { 310 | if err != nil { 311 | panic(err) 312 | } 313 | return value 314 | } 315 | 316 | func validateNonNilPointer(value any) { 317 | val := reflect.ValueOf(value) 318 | 319 | if val.Kind() != reflect.Ptr || val.IsNil() { 320 | panic(fmt.Sprintf("Argument must be a non-nil pointer to a struct, got %T", value)) 321 | } 322 | } 323 | 324 | func validateFactoryFunction[I any, T any](fn any) { 325 | // Factory function must return a pointer to a struct that implements I 326 | // I and T must be the same pointer type. 327 | // This way pal can inspect the type of the returned value to build the correct dependency tree. 328 | if reflect.TypeOf(fn).Out(0).Kind() != reflect.Ptr { 329 | panic(fmt.Sprintf("Factory function must return a pointer, got %s", reflect.TypeOf(fn).Out(0).Kind())) 330 | } 331 | 332 | if typetostring.GetType[I]() == typetostring.GetType[T]() { 333 | return 334 | } 335 | 336 | iType := reflect.TypeOf((*I)(nil)).Elem() 337 | tType := reflect.TypeOf((*T)(nil)).Elem() 338 | 339 | if iType.Kind() != reflect.Interface { 340 | panic(fmt.Sprintf("I must be an interface, got %s", iType.Kind())) 341 | } 342 | 343 | if !tType.Implements(iType) { 344 | panic(fmt.Sprintf("T (%s) must implement interface I (%s)", tType, iType)) 345 | } 346 | } 347 | -------------------------------------------------------------------------------- /api_test.go: -------------------------------------------------------------------------------- 1 | package pal_test 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/mock" 8 | "github.com/stretchr/testify/require" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/zhulik/pal" 12 | ) 13 | 14 | // Test interfaces and implementations are defined in common_test.go 15 | 16 | // TestProvide tests the Provide function 17 | func TestProvide(t *testing.T) { 18 | t.Parallel() 19 | 20 | t.Run("creates a singleton service", func(t *testing.T) { 21 | t.Parallel() 22 | 23 | service := pal.Provide(NewMockTestServiceStruct(t)) 24 | 25 | assert.NotNil(t, service) 26 | assert.Equal(t, "*github.com/zhulik/pal_test.TestServiceStruct", service.Name()) 27 | }) 28 | 29 | t.Run("detects runner services", func(t *testing.T) { 30 | t.Parallel() 31 | 32 | service := pal.Provide(NewMockRunnerServiceStruct(t)). 33 | ToInit(func(ctx context.Context, service *RunnerServiceStruct, _ *pal.Pal) error { 34 | service.MockRunner.EXPECT().Run(ctx).Return(nil) 35 | 36 | return nil 37 | }) 38 | 39 | assert.NotNil(t, service) 40 | assert.Equal(t, "*github.com/zhulik/pal_test.RunnerServiceStruct", service.Name()) 41 | }) 42 | 43 | t.Run("makes sure the argument is a pointer to struct", func(t *testing.T) { 44 | t.Parallel() 45 | 46 | require.PanicsWithValue(t, "Argument must be a non-nil pointer to a struct, got func()", func() { 47 | pal.Provide(func() {}) 48 | }) 49 | }) 50 | } 51 | 52 | // TestProvideFn tests the ProvideFn function 53 | func TestProvideFn(t *testing.T) { 54 | t.Parallel() 55 | 56 | t.Run("creates a singleton service with a function", func(t *testing.T) { 57 | t.Parallel() 58 | 59 | service := pal.ProvideFn[TestServiceInterface](func(ctx context.Context) (*TestServiceStruct, error) { 60 | s := NewMockTestServiceStruct(t) 61 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 62 | return s, nil 63 | }) 64 | 65 | assert.NotNil(t, service) 66 | assert.Equal(t, "github.com/zhulik/pal_test.TestServiceInterface", service.Name()) 67 | }) 68 | } 69 | 70 | // TestProvideFactory0 tests the ProvideFactory0 function 71 | func TestProvideFactory0(t *testing.T) { 72 | t.Parallel() 73 | 74 | t.Run("creates a factory service with a function", func(t *testing.T) { 75 | t.Parallel() 76 | 77 | service := pal.ProvideFactory0[TestServiceInterface](func(_ context.Context) (*TestServiceStruct, error) { 78 | return NewMockTestServiceStruct(t), nil 79 | }) 80 | 81 | assert.NotNil(t, service) 82 | assert.Equal(t, "github.com/zhulik/pal_test.TestServiceInterface", service.Name()) 83 | }) 84 | } 85 | 86 | // TestProvideNamed tests the ProvideNamed function 87 | func TestProvideNamed(t *testing.T) { 88 | t.Parallel() 89 | 90 | t.Run("creates a const service with a given name", func(t *testing.T) { 91 | t.Parallel() 92 | 93 | service := pal.ProvideNamed("test", NewMockTestServiceStruct(t)) 94 | 95 | assert.NotNil(t, service) 96 | assert.Equal(t, "test", service.Name()) 97 | }) 98 | } 99 | 100 | // TestInvoke tests the Invoke function 101 | func TestInvoke(t *testing.T) { 102 | t.Parallel() 103 | 104 | t.Run("invokes a service successfully", func(t *testing.T) { 105 | t.Parallel() 106 | 107 | p := newPal( 108 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 109 | s := NewMockTestServiceStruct(t) 110 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 111 | return s, nil 112 | }), 113 | ) 114 | 115 | require.NoError(t, p.Init(t.Context())) 116 | 117 | instance, err := pal.Invoke[*TestServiceStruct](t.Context(), p) 118 | 119 | assert.NoError(t, err) 120 | assert.NotNil(t, instance) 121 | }) 122 | 123 | t.Run("returns error when service not found", func(t *testing.T) { 124 | t.Parallel() 125 | 126 | // Create an empty Pal instance 127 | p := newPal() 128 | 129 | // Try to invoke a non-existent service 130 | _, err := pal.Invoke[TestServiceInterface](t.Context(), p) 131 | 132 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 133 | }) 134 | } 135 | 136 | func TestInvokeNamed(t *testing.T) { 137 | t.Parallel() 138 | 139 | t.Run("invokes a service successfully with a given name", func(t *testing.T) { 140 | t.Parallel() 141 | 142 | p := newPal(pal.ProvideNamed("test", NewMockTestServiceStruct(t))) 143 | 144 | instance, err := pal.InvokeNamed[TestServiceInterface](t.Context(), p, "test") 145 | 146 | assert.NoError(t, err) 147 | assert.NotNil(t, instance) 148 | }) 149 | 150 | t.Run("returns error when service not found", func(t *testing.T) { 151 | t.Parallel() 152 | 153 | p := newPal() 154 | 155 | _, err := pal.InvokeNamed[TestServiceInterface](t.Context(), p, "test") 156 | 157 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 158 | }) 159 | } 160 | 161 | func TestInvokeAs(t *testing.T) { 162 | t.Parallel() 163 | 164 | t.Run("invokes a service successfully", func(t *testing.T) { 165 | t.Parallel() 166 | 167 | p := newPal(pal.Provide[TestServiceInterface](NewMockTestServiceStruct(t))) 168 | 169 | instance, err := pal.InvokeAs[TestServiceInterface, TestServiceStruct](t.Context(), p) 170 | 171 | assert.NoError(t, err) 172 | assert.NotNil(t, instance) 173 | }) 174 | 175 | t.Run("returns error when service cannot be cast to the expected type", func(t *testing.T) { 176 | t.Parallel() 177 | 178 | p := newPal(pal.Provide[TestServiceInterface](NewMockTestServiceStruct(t))) 179 | 180 | _, err := pal.InvokeAs[TestServiceInterface, string](t.Context(), p) 181 | 182 | assert.ErrorIs(t, err, pal.ErrServiceInvalidCast) 183 | }) 184 | } 185 | 186 | func TestInvokeByInterface(t *testing.T) { 187 | t.Parallel() 188 | 189 | t.Run("when there is only one service that implements the interface, it returns the service", func(t *testing.T) { 190 | t.Parallel() 191 | 192 | pinger := &Pinger1{} 193 | 194 | p := newPal(pal.Provide(pinger)) 195 | 196 | instance, err := pal.InvokeByInterface[Pinger](t.Context(), p) 197 | 198 | assert.NoError(t, err) 199 | assert.Equal(t, pinger, instance) 200 | }) 201 | 202 | t.Run("when there is no service implementing the interface, it returns an error", func(t *testing.T) { 203 | t.Parallel() 204 | 205 | p := newPal() 206 | 207 | _, err := pal.InvokeByInterface[Pinger](t.Context(), p) 208 | 209 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 210 | }) 211 | 212 | t.Run("when there is multiple services implementing the interface, it returns an error", func(t *testing.T) { 213 | t.Parallel() 214 | 215 | p := newPal( 216 | pal.Provide(&Pinger1{}), 217 | pal.Provide(&Pinger2{}), 218 | ) 219 | 220 | _, err := pal.InvokeByInterface[Pinger](t.Context(), p) 221 | 222 | assert.ErrorIs(t, err, pal.ErrMultipleServicesFoundByInterface) 223 | }) 224 | 225 | t.Run("when the interface is not an interface, it returns an error", func(t *testing.T) { 226 | t.Parallel() 227 | 228 | p := newPal() 229 | 230 | _, err := pal.InvokeByInterface[string](t.Context(), p) 231 | 232 | assert.ErrorIs(t, err, pal.ErrNotAnInterface) 233 | }) 234 | } 235 | 236 | // TestBuild tests the Build function 237 | func TestBuild(t *testing.T) { 238 | t.Parallel() 239 | 240 | t.Run("injects dependencies successfully", func(t *testing.T) { 241 | t.Parallel() 242 | 243 | p := newPal( 244 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 245 | s := NewMockTestServiceStruct(t) 246 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 247 | return s, nil 248 | }), 249 | ) 250 | 251 | require.NoError(t, p.Init(t.Context())) 252 | 253 | type DependentStruct struct { 254 | Dependency *TestServiceStruct 255 | } 256 | 257 | instance, err := pal.Build[DependentStruct](t.Context(), p) 258 | 259 | assert.NoError(t, err) 260 | assert.NotNil(t, instance) 261 | assert.NotNil(t, instance.Dependency) 262 | }) 263 | 264 | t.Run("ignores missing dependencies", func(t *testing.T) { 265 | t.Parallel() 266 | 267 | // Create an empty Pal instance 268 | p := newPal() 269 | 270 | // Try to inject dependencies with no services registered 271 | _, err := pal.Build[DependentStruct](t.Context(), p) 272 | 273 | assert.NoError(t, err) 274 | }) 275 | 276 | t.Run("skips non-interface fields", func(t *testing.T) { 277 | t.Parallel() 278 | 279 | type StructWithNonInterfaceField struct { 280 | NonInterface string 281 | } 282 | 283 | // Create an empty Pal instance 284 | p := newPal() 285 | 286 | // Build dependencies into a struct with no interface fields 287 | result, err := pal.Build[StructWithNonInterfaceField](t.Context(), p) 288 | 289 | assert.NoError(t, err) 290 | assert.NotNil(t, result) 291 | assert.Equal(t, "", result.NonInterface) // Default value is empty string 292 | }) 293 | 294 | t.Run("skips unexported fields", func(t *testing.T) { 295 | t.Parallel() 296 | 297 | type StructWithUnexportedField struct { 298 | dependency TestServiceInterface 299 | } 300 | 301 | // Create a Pal instance with our test service 302 | p := newPal(pal.Provide(NewMockTestServiceStruct(t))) 303 | 304 | // No need to initialize Pal for this test 305 | 306 | // Build dependencies into a struct with unexported fields 307 | result, err := pal.Build[StructWithUnexportedField](t.Context(), p) 308 | 309 | assert.NoError(t, err) 310 | assert.NotNil(t, result) 311 | assert.Nil(t, result.dependency) // Field is unexported, so it's not set 312 | }) 313 | } 314 | 315 | // TestInjectInto tests the InjectInto function 316 | func TestInjectInto(t *testing.T) { 317 | t.Parallel() 318 | 319 | t.Run("injects dependencies successfully", func(t *testing.T) { 320 | t.Parallel() 321 | 322 | p := newPal( 323 | pal.ProvideFn[*TestServiceStruct](func(ctx context.Context) (*TestServiceStruct, error) { 324 | s := NewMockTestServiceStruct(t) 325 | s.MockIniter.EXPECT().Init(ctx).Return(nil) 326 | return s, nil 327 | }), 328 | ) 329 | 330 | require.NoError(t, p.Init(t.Context())) 331 | 332 | // Create a struct instance to inject dependencies into 333 | instance := &DependentStruct{} 334 | 335 | // Inject dependencies 336 | err := pal.InjectInto(t.Context(), p, instance) 337 | 338 | assert.NoError(t, err) 339 | assert.NotNil(t, instance.Dependency) 340 | }) 341 | 342 | t.Run("ignores missing dependencies", func(t *testing.T) { 343 | t.Parallel() 344 | 345 | // Create an empty Pal instance 346 | p := newPal() 347 | 348 | // Create a struct instance to inject dependencies into 349 | instance := &DependentStruct{} 350 | 351 | // Try to inject dependencies with no services registered 352 | err := pal.InjectInto(t.Context(), p, instance) 353 | 354 | assert.NoError(t, err) 355 | assert.Nil(t, instance.Dependency) // Dependency should remain nil 356 | }) 357 | 358 | t.Run("skips non-interface fields", func(t *testing.T) { 359 | t.Parallel() 360 | 361 | type StructWithNonInterfaceField struct { 362 | NonInterface string 363 | } 364 | 365 | // Create an empty Pal instance 366 | p := newPal() 367 | 368 | // Create a struct instance to inject dependencies into 369 | instance := &StructWithNonInterfaceField{NonInterface: "original value"} 370 | 371 | // Inject dependencies 372 | err := pal.InjectInto(t.Context(), p, instance) 373 | 374 | assert.NoError(t, err) 375 | assert.Equal(t, "original value", instance.NonInterface) // Value should remain unchanged 376 | }) 377 | 378 | t.Run("skips unexported fields", func(t *testing.T) { 379 | t.Parallel() 380 | 381 | type StructWithUnexportedField struct { 382 | dependency *TestServiceStruct 383 | } 384 | 385 | // Create a Pal instance with our test service 386 | p := newPal(pal.Provide(NewMockTestServiceStruct(t))) 387 | 388 | // Create a struct instance to inject dependencies into 389 | instance := &StructWithUnexportedField{} 390 | 391 | // Inject dependencies 392 | err := pal.InjectInto(t.Context(), p, instance) 393 | 394 | assert.NoError(t, err) 395 | assert.Nil(t, instance.dependency) // Field is unexported, so it's not set 396 | }) 397 | 398 | t.Run("skips fields with skip tag", func(t *testing.T) { 399 | t.Parallel() 400 | 401 | type StructWithSkipField struct { 402 | Dependency *TestServiceStruct `pal:"skip"` 403 | } 404 | 405 | p := newPal(pal.Provide(NewMockTestServiceStruct(t))) 406 | 407 | instance := &StructWithSkipField{} 408 | 409 | err := pal.InjectInto(t.Context(), p, instance) 410 | 411 | assert.NoError(t, err) 412 | assert.Nil(t, instance.Dependency) // Field is skipped, so it's not set 413 | }) 414 | 415 | t.Run("injects dependencies by interface if service is provided", func(t *testing.T) { 416 | t.Parallel() 417 | 418 | type StructWithSkipField struct { 419 | Pinger Pinger `pal:"match_interface"` 420 | } 421 | 422 | pinger := &Pinger1{} 423 | 424 | // Provide by pointer 425 | p := newPal(pal.Provide(pinger)) 426 | 427 | instance := &StructWithSkipField{} 428 | 429 | err := pal.InjectInto(t.Context(), p, instance) 430 | 431 | assert.NoError(t, err) 432 | 433 | // The field was matched by interface 434 | assert.Equal(t, instance.Pinger, pinger) 435 | }) 436 | 437 | t.Run("returns an error if service that should be matched by interface is not provided", func(t *testing.T) { 438 | t.Parallel() 439 | 440 | type StructWithSkipField struct { 441 | Pinger Pinger `pal:"match_interface"` 442 | } 443 | 444 | p := newPal() 445 | 446 | instance := &StructWithSkipField{} 447 | 448 | err := pal.InjectInto(t.Context(), p, instance) 449 | 450 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 451 | }) 452 | 453 | t.Run("injects service by name if service is provided", func(t *testing.T) { 454 | t.Parallel() 455 | 456 | type StructWithSkipField struct { 457 | Pinger Pinger `pal:"name=*github.com/zhulik/pal_test.Pinger1"` 458 | } 459 | 460 | pinger := &Pinger1{} 461 | // Provide by pointer 462 | p := newPal(pal.Provide(pinger)) 463 | 464 | instance := &StructWithSkipField{} 465 | 466 | err := pal.InjectInto(t.Context(), p, instance) 467 | 468 | assert.NoError(t, err) 469 | 470 | // The field was matched by name 471 | assert.Equal(t, instance.Pinger, pinger) 472 | }) 473 | 474 | t.Run("returns an error if service that should be matched by name is not provided", func(t *testing.T) { 475 | t.Parallel() 476 | 477 | type StructWithSkipField struct { 478 | Pinger Pinger `pal:"name=*github.com/zhulik/pal_test.Pinger1"` 479 | } 480 | 481 | p := newPal() 482 | 483 | instance := &StructWithSkipField{} 484 | 485 | err := pal.InjectInto(t.Context(), p, instance) 486 | 487 | assert.ErrorIs(t, err, pal.ErrServiceNotFound) 488 | }) 489 | 490 | t.Run("returns error when service invocation fails", func(t *testing.T) { 491 | t.Parallel() 492 | 493 | // Create a struct instance to inject dependencies into 494 | instance := &DependentStruct{} 495 | 496 | // Create a mock invoker that returns an error 497 | mockInvoker := NewMockInvoker(t) 498 | mockInvoker.EXPECT().InjectInto(mock.Anything, instance).Return(errTest) 499 | 500 | // Inject dependencies 501 | err := pal.InjectInto(t.Context(), mockInvoker, instance) 502 | 503 | assert.ErrorIs(t, err, errTest) 504 | assert.Nil(t, instance.Dependency) // Dependency should remain nil 505 | }) 506 | } 507 | --------------------------------------------------------------------------------