├── 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 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | | ID |
26 | |
27 |
28 |
29 | | In Degree |
30 | |
31 |
32 |
33 | | Out Degree |
34 | |
35 |
36 |
37 | | Initer |
38 | |
39 |
40 |
41 | | Runner |
42 | |
43 |
44 |
45 | | Health Checker |
46 | |
47 |
48 |
49 | | Shutdowner |
50 | |
51 |
52 |
53 |
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 |
--------------------------------------------------------------------------------