From 3f5edf06d45d8595b3fb1565636cac645898cb51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 09:54:44 -0300 Subject: [PATCH 01/11] docs(gitflow): a release has a milestone with its issues (#198) --- docs/development/gitflow.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/development/gitflow.md b/docs/development/gitflow.md index 810a01b5..30d2d59b 100644 --- a/docs/development/gitflow.md +++ b/docs/development/gitflow.md @@ -48,6 +48,9 @@ Release só quando o mantenedor mandar lançar, sempre por `release/vX.Y.Z`. Release **não tem issue**: o PR `release/vX.Y.Z` → `main` é o registro dela. +0. Milestone `vX.Y.Z` (`gh api repos/xgodev/boost/milestones -f title=vX.Y.Z`) + com todas as issues mergeadas em `develop` desde a tag anterior + (`gh issue edit N --milestone vX.Y.Z`); fechar o milestone no passo 4. 1. `git checkout -b release/vX.Y.Z origin/develop`. Versão: patch para fix/deps, minor para feature. Mudança de API pequena vai como minor, com nota no release; major (`/v2` no import) só por decisão do mantenedor. From 69872b3b523c28fd6f80bd351a0974af51679f53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 09:56:39 -0300 Subject: [PATCH 02/11] docs(gitflow): issue has assignee and next release milestone (#200) --- docs/development/gitflow.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/development/gitflow.md b/docs/development/gitflow.md index 30d2d59b..53ac3b88 100644 --- a/docs/development/gitflow.md +++ b/docs/development/gitflow.md @@ -18,6 +18,9 @@ Issue → Branch (de develop) → Commits → PR → Review/Merge 1. **Issue sempre, primeiro.** Toda mudança (feature/bug/docs) tem issue no GitHub antes de qualquer código. `gh issue list --search` antes de criar (evita duplicata). Label apropriada (`documentation`, `bug`, `enhancement`). + Responsável `jpfaria` (`--assignee jpfaria`) e o milestone aberto da + próxima release (`--milestone vX.Y.Z`); sem milestone aberto, criar o do + próximo patch. 2. **PR sempre.** Nada commitado direto em `develop` ou `main`. Toda mudança entra por Pull Request — sem exceção. 3. **Nome de branch: `feature/{N}-slug` / `bugfix/{N}-slug`** (`{N}` = número From 764a3ba748f485da268439ec71b3d79437a033a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 10:28:20 -0300 Subject: [PATCH 03/11] fix(publisher): NATS driver no longer logs the event payload (#209) Every publish wrote the whole event, payload included, at INFO: one line per message in production, with data that must not reach the logs. Log only that the message was published, at DEBUG, with the id and subject fields, as the Pub/Sub driver does. --- .../driver/contrib/nats-io/nats.go/v1/nats.go | 6 +- .../contrib/nats-io/nats.go/v1/nats_test.go | 160 ++++++++++++++++++ 2 files changed, 164 insertions(+), 2 deletions(-) create mode 100644 wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats_test.go diff --git a/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats.go b/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats.go index 92f1a53d..868de3db 100644 --- a/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats.go +++ b/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats.go @@ -69,8 +69,6 @@ func (p *client) Publish(ctx context.Context, outs []*v2.Event) (res []publisher continue } - logger.Info(string(rawMessage)) - msg := &nats.Msg{ Subject: out.Subject(), Data: rawMessage, @@ -82,6 +80,10 @@ func (p *client) Publish(ctx context.Context, outs []*v2.Event) (res []publisher continue } + // The payload is never logged: it may carry data that must not reach + // the logs, and one line per message at INFO is volume nobody reads. + logger.Debug("message published") + res = append(res, publisher.PublishOutput{Event: out}) } diff --git a/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats_test.go b/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats_test.go new file mode 100644 index 00000000..1e36f852 --- /dev/null +++ b/wrapper/publisher/driver/contrib/nats-io/nats.go/v1/nats_test.go @@ -0,0 +1,160 @@ +package nats + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "strings" + "sync" + "testing" + "time" + + v2 "github.com/cloudevents/sdk-go/v2" + natsserver "github.com/nats-io/nats-server/v2/test" + natsgo "github.com/nats-io/nats.go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/wrapper/log" +) + +// entry is one line a recorder received: its level and everything printed on it. +type entry struct { + level string + text string +} + +// recorder is a log.Logger that keeps every line, with its fields, in memory. +type recorder struct { + mu *sync.Mutex + entries *[]entry + fields log.Fields +} + +func newRecorder() *recorder { + return &recorder{mu: &sync.Mutex{}, entries: &[]entry{}, fields: log.Fields{}} +} + +func (r *recorder) add(level, msg string) { + r.mu.Lock() + defer r.mu.Unlock() + *r.entries = append(*r.entries, entry{level: level, text: fmt.Sprintf("%s %v", msg, r.fields)}) +} + +func (r *recorder) lines() []entry { + r.mu.Lock() + defer r.mu.Unlock() + return append([]entry(nil), *r.entries...) +} + +func (r *recorder) with(f log.Fields) log.Logger { + merged := log.Fields{} + for k, v := range r.fields { + merged[k] = v + } + for k, v := range f { + merged[k] = v + } + return &recorder{mu: r.mu, entries: r.entries, fields: merged} +} + +func (r *recorder) Printf(format string, args ...interface{}) { + r.add("print", fmt.Sprintf(format, args...)) +} +func (r *recorder) Tracef(format string, args ...interface{}) { + r.add("trace", fmt.Sprintf(format, args...)) +} +func (r *recorder) Trace(args ...interface{}) { r.add("trace", fmt.Sprint(args...)) } +func (r *recorder) Debugf(format string, args ...interface{}) { + r.add("debug", fmt.Sprintf(format, args...)) +} +func (r *recorder) Debug(args ...interface{}) { r.add("debug", fmt.Sprint(args...)) } +func (r *recorder) Infof(format string, args ...interface{}) { + r.add("info", fmt.Sprintf(format, args...)) +} +func (r *recorder) Info(args ...interface{}) { r.add("info", fmt.Sprint(args...)) } +func (r *recorder) Warnf(format string, args ...interface{}) { + r.add("warn", fmt.Sprintf(format, args...)) +} +func (r *recorder) Warn(args ...interface{}) { r.add("warn", fmt.Sprint(args...)) } +func (r *recorder) Errorf(format string, args ...interface{}) { + r.add("error", fmt.Sprintf(format, args...)) +} +func (r *recorder) Error(args ...interface{}) { r.add("error", fmt.Sprint(args...)) } +func (r *recorder) Fatalf(format string, args ...interface{}) { + r.add("fatal", fmt.Sprintf(format, args...)) +} +func (r *recorder) Fatal(args ...interface{}) { r.add("fatal", fmt.Sprint(args...)) } +func (r *recorder) Panicf(format string, args ...interface{}) { + r.add("panic", fmt.Sprintf(format, args...)) +} +func (r *recorder) Panic(args ...interface{}) { r.add("panic", fmt.Sprint(args...)) } + +func (r *recorder) WithFields(keyValues map[string]interface{}) log.Logger { return r.with(keyValues) } +func (r *recorder) WithField(key string, value interface{}) log.Logger { + return r.with(log.Fields{key: value}) +} +func (r *recorder) WithError(err error) log.Logger { return r.with(log.Fields{"error": err}) } +func (r *recorder) WithTypeOf(obj interface{}) log.Logger { + return r.with(log.Fields{"type": fmt.Sprintf("%T", obj)}) +} +func (r *recorder) ToContext(ctx context.Context) context.Context { return ctx } +func (r *recorder) FromContext(context.Context) log.Logger { return r } +func (r *recorder) Output() io.Writer { return io.Discard } +func (r *recorder) Fields() log.Fields { return r.fields } + +func TestPublish_NeverLogsThePayloadAndLogsTheIDAtDebug(t *testing.T) { + boost.Start() + opts := natsserver.DefaultTestOptions + opts.Port = -1 + srv := natsserver.RunServer(&opts) + defer srv.Shutdown() + + conn, err := natsgo.Connect(srv.ClientURL()) + require.NoError(t, err) + defer conn.Close() + + received := make(chan *natsgo.Msg, 1) + sub, err := conn.ChanSubscribe("prices", received) + require.NoError(t, err) + defer func() { _ = sub.Unsubscribe() }() // intentional: the server shuts down right after + require.NoError(t, conn.Flush()) + + const secret = "card-4111-1111" + e := v2.NewEvent() + e.SetID("evt-209") + e.SetSource("axis-price") + e.SetType("price") + e.SetSubject("prices") + require.NoError(t, e.SetData(v2.ApplicationJSON, map[string]string{"secret": secret})) + + rec := newRecorder() + previous := log.GetLogger() + log.Set(rec) + defer log.Set(previous) + + out, err := New(conn).Publish(context.Background(), []*v2.Event{&e}) + require.NoError(t, err) + require.Len(t, out, 1) + assert.NoError(t, out[0].Error) + + select { + case msg := <-received: + assert.Contains(t, string(msg.Data), "evt-209") + case <-time.After(5 * time.Second): + t.Fatal("the event was not published") + } + + encoded := base64.StdEncoding.EncodeToString(e.Data()) + var debugMentionsID bool + for _, l := range rec.lines() { + assert.NotContains(t, l.text, secret, "payload logged at %s", l.level) + assert.NotContains(t, l.text, encoded, "payload logged at %s", l.level) + assert.Contains(t, []string{"trace", "debug"}, l.level, "a publish logs nothing at info or above, got %q", l.text) + if strings.Contains(l.text, "evt-209") && strings.Contains(l.text, "prices") { + debugMentionsID = true + } + } + assert.True(t, debugMentionsID, "no debug line with the event id and subject: %v", rec.lines()) +} From 5419afa24f4449b12d22eba49c009e5636270d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 10:34:46 -0300 Subject: [PATCH 04/11] fix(fx): errors that stop the app are logged at ERROR whatever the fx level (#206) fx's lines went through a printer at boost.factory.fx.log.level, DEBUG by default, so 'Failed to start' was hidden too and a builder app with bad config exited 1 with no message: a pod restarting with no reason. NewApp now installs an fxevent logger that renders fx's console lines and writes every event carrying an error (failed provide/invoke/hook, start, stop, rollback) at ERROR, the rest at the fx level. --- .../examples/simple/cmd/worker/main_test.go | 28 +++ factory/contrib/go.uber.org/fx/v1/config.go | 2 +- .../contrib/go.uber.org/fx/v1/event_logger.go | 73 +++++++ factory/contrib/go.uber.org/fx/v1/fx.go | 5 +- .../contrib/go.uber.org/fx/v1/logger_test.go | 181 ++++++++++++++++++ 5 files changed, 286 insertions(+), 3 deletions(-) create mode 100644 factory/contrib/go.uber.org/fx/v1/event_logger.go create mode 100644 factory/contrib/go.uber.org/fx/v1/logger_test.go diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index 13ae63ed..0fa78c02 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -120,6 +120,34 @@ func TestWorkerAdapter(t *testing.T) { } } +// mainEnv makes the process TestMain_BadConfigExitsWithTheReason starts run +// the generated main instead of the tests. +const mainEnv = "BUILDER_EXAMPLE_MAIN" + +// TestMain_BadConfigExitsWithTheReason runs the generated main of app worker +// with an unknown adapter and every log level at its default: the process +// exits non-zero and says why, so a pod that restarts shows its reason. +func TestMain_BadConfigExitsWithTheReason(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(os.Environ(), adapterEnv+"=nope", mainEnv+"=1") + out, err := cmd.CombinedOutput() + + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "the main must fail: %s", out) + assert.NotZero(t, exit.ExitCode()) + assert.Contains(t, string(out), "worker.function.adapter") + assert.Contains(t, string(out), `unknown adapter "nope"`) +} + +// TestMainChild runs the generated main in the process +// TestMain_BadConfigExitsWithTheReason starts. +func TestMainChild(t *testing.T) { + if os.Getenv(mainEnv) != "1" { + t.Skip("run by TestMain_BadConfigExitsWithTheReason") + } + main() +} + // TestWorkerAdapterChild runs in the process TestWorkerAdapter starts, with // the adapter and driver keys set. The adapter and the publisher share the // one default client of their broker. diff --git a/factory/contrib/go.uber.org/fx/v1/config.go b/factory/contrib/go.uber.org/fx/v1/config.go index cd41e718..705947a3 100644 --- a/factory/contrib/go.uber.org/fx/v1/config.go +++ b/factory/contrib/go.uber.org/fx/v1/config.go @@ -10,7 +10,7 @@ const ( ) func init() { - config.Add(logLevel, "DEBUG", "define log level") + config.Add(logLevel, "DEBUG", "level of fx's lines (TRACE, DEBUG, INFO); an error that stops the app is always logged at ERROR") } func LogLevel() string { diff --git a/factory/contrib/go.uber.org/fx/v1/event_logger.go b/factory/contrib/go.uber.org/fx/v1/event_logger.go new file mode 100644 index 00000000..6fba1494 --- /dev/null +++ b/factory/contrib/go.uber.org/fx/v1/event_logger.go @@ -0,0 +1,73 @@ +package fx + +import ( + "bytes" + "strings" + + "github.com/xgodev/boost/wrapper/log" + "go.uber.org/fx" + "go.uber.org/fx/fxevent" +) + +// EventLogger is the fx event logger NewApp installs. It writes fx's lines +// through the boost logger at the fx log level, and every event that carries +// an error (a constructor, invoke or hook that failed, an app that did not +// start or stop) at the error level whatever the fx log level, so the reason +// an app exits always reaches the output. +type EventLogger struct { + printer fx.Printer +} + +// NewEventLogger returns the fx event logger, at the fx log level. +func NewEventLogger() fxevent.Logger { + return &EventLogger{printer: NewLogger()} +} + +// LogEvent writes the line fx's console logger renders for event. +func (l *EventLogger) LogEvent(event fxevent.Event) { + var line bytes.Buffer + (&fxevent.ConsoleLogger{W: &line}).LogEvent(event) + text := strings.TrimSuffix(line.String(), "\n") + if text == "" { + return + } + if eventErr(event) != nil { + log.Error(text) + return + } + l.printer.Printf("%s", text) +} + +// eventErr is the error event carries, nil when it carries none. +func eventErr(event fxevent.Event) error { + switch e := event.(type) { + case *fxevent.OnStartExecuted: + return e.Err + case *fxevent.OnStopExecuted: + return e.Err + case *fxevent.Supplied: + return e.Err + case *fxevent.Provided: + return e.Err + case *fxevent.Replaced: + return e.Err + case *fxevent.Decorated: + return e.Err + case *fxevent.Run: + return e.Err + case *fxevent.Invoked: + return e.Err + case *fxevent.Started: + return e.Err + case *fxevent.Stopped: + return e.Err + case *fxevent.RollingBack: + return e.StartErr + case *fxevent.RolledBack: + return e.Err + case *fxevent.LoggerInitialized: + return e.Err + default: + return nil + } +} diff --git a/factory/contrib/go.uber.org/fx/v1/fx.go b/factory/contrib/go.uber.org/fx/v1/fx.go index 12498b3d..f35e6701 100644 --- a/factory/contrib/go.uber.org/fx/v1/fx.go +++ b/factory/contrib/go.uber.org/fx/v1/fx.go @@ -4,8 +4,9 @@ import ( "go.uber.org/fx" ) -// NewApp start a new fx app with options. +// NewApp start a new fx app with options, logging fx's events with +// NewEventLogger. func NewApp(opts ...fx.Option) *fx.App { - opts = append([]fx.Option{fx.Logger(NewLogger())}, opts...) + opts = append([]fx.Option{fx.WithLogger(NewEventLogger)}, opts...) return fx.New(opts...) } diff --git a/factory/contrib/go.uber.org/fx/v1/logger_test.go b/factory/contrib/go.uber.org/fx/v1/logger_test.go new file mode 100644 index 00000000..e7f2035e --- /dev/null +++ b/factory/contrib/go.uber.org/fx/v1/logger_test.go @@ -0,0 +1,181 @@ +package fx + +import ( + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/wrapper/log" + "go.uber.org/fx" + "go.uber.org/fx/fxevent" +) + +// entry is one line a recorder received: its level and its text. +type entry struct { + level string + text string +} + +// recorder is a log.Logger that keeps every line in memory. +type recorder struct { + mu sync.Mutex + entries []entry +} + +func (r *recorder) add(level, msg string) { + r.mu.Lock() + defer r.mu.Unlock() + r.entries = append(r.entries, entry{level: level, text: msg}) +} + +func (r *recorder) lines() []entry { + r.mu.Lock() + defer r.mu.Unlock() + return append([]entry(nil), r.entries...) +} + +func (r *recorder) Printf(format string, args ...interface{}) { + r.add("print", fmt.Sprintf(format, args...)) +} +func (r *recorder) Tracef(format string, args ...interface{}) { + r.add("trace", fmt.Sprintf(format, args...)) +} +func (r *recorder) Trace(args ...interface{}) { r.add("trace", fmt.Sprint(args...)) } +func (r *recorder) Debugf(format string, args ...interface{}) { + r.add("debug", fmt.Sprintf(format, args...)) +} +func (r *recorder) Debug(args ...interface{}) { r.add("debug", fmt.Sprint(args...)) } +func (r *recorder) Infof(format string, args ...interface{}) { + r.add("info", fmt.Sprintf(format, args...)) +} +func (r *recorder) Info(args ...interface{}) { r.add("info", fmt.Sprint(args...)) } +func (r *recorder) Warnf(format string, args ...interface{}) { + r.add("warn", fmt.Sprintf(format, args...)) +} +func (r *recorder) Warn(args ...interface{}) { r.add("warn", fmt.Sprint(args...)) } +func (r *recorder) Errorf(format string, args ...interface{}) { + r.add("error", fmt.Sprintf(format, args...)) +} +func (r *recorder) Error(args ...interface{}) { r.add("error", fmt.Sprint(args...)) } +func (r *recorder) Fatalf(format string, args ...interface{}) { + r.add("fatal", fmt.Sprintf(format, args...)) +} +func (r *recorder) Fatal(args ...interface{}) { r.add("fatal", fmt.Sprint(args...)) } +func (r *recorder) Panicf(format string, args ...interface{}) { + r.add("panic", fmt.Sprintf(format, args...)) +} +func (r *recorder) Panic(args ...interface{}) { r.add("panic", fmt.Sprint(args...)) } + +func (r *recorder) WithFields(map[string]interface{}) log.Logger { return r } +func (r *recorder) WithField(string, interface{}) log.Logger { return r } +func (r *recorder) WithError(error) log.Logger { return r } +func (r *recorder) WithTypeOf(interface{}) log.Logger { return r } +func (r *recorder) ToContext(ctx context.Context) context.Context { return ctx } +func (r *recorder) FromContext(context.Context) log.Logger { return r } +func (r *recorder) Output() io.Writer { return io.Discard } +func (r *recorder) Fields() log.Fields { return log.Fields{} } + +func record(t *testing.T) *recorder { + t.Helper() + rec := &recorder{} + previous := log.GetLogger() + log.Set(rec) + t.Cleanup(func() { log.Set(previous) }) + return rec +} + +// TestNewApp_ABootErrorIsLoggedAtErrorWhateverTheFxLevel boots an app whose +// invoke fails, with the fx log level at its default: the reason the app +// does not start reaches the error level, the rest of fx's lines stay at the +// configured level. +func TestNewApp_ABootErrorIsLoggedAtErrorWhateverTheFxLevel(t *testing.T) { + boost.Start() + rec := record(t) + + app := NewApp( + fx.Provide(func() string { return "provided" }), + fx.Invoke(func(string) error { return errors.New("boom-206") }), + ) + require.Error(t, app.Start(context.Background())) + + var reason bool + for _, l := range rec.lines() { + if strings.Contains(l.text, "boom-206") && l.level == "error" { + reason = true + } + if strings.Contains(l.text, "PROVIDE") { + assert.Equal(t, strings.ToLower(LogLevel()), l.level, "an fx line that is not an error stays at the fx level: %q", l.text) + } + } + assert.True(t, reason, "no error-level line with the boot error: %v", rec.lines()) +} + +// TestEventLogger_EveryEventWithAnErrorIsLoggedAtError covers each fx event +// that can carry an error, with and without it. +func TestEventLogger_EveryEventWithAnErrorIsLoggedAtError(t *testing.T) { + boost.Start() + boom := errors.New("boom") + for _, c := range []struct { + name string + failed fxevent.Event + ok fxevent.Event + }{ + {"OnStartExecuted", &fxevent.OnStartExecuted{FunctionName: "start", Err: boom}, &fxevent.OnStartExecuted{FunctionName: "start"}}, + {"OnStopExecuted", &fxevent.OnStopExecuted{FunctionName: "stop", Err: boom}, &fxevent.OnStopExecuted{FunctionName: "stop"}}, + {"Supplied", &fxevent.Supplied{TypeName: "string", Err: boom}, &fxevent.Supplied{TypeName: "string"}}, + {"Provided", &fxevent.Provided{OutputTypeNames: []string{"string"}, Err: boom}, &fxevent.Provided{OutputTypeNames: []string{"string"}}}, + {"Replaced", &fxevent.Replaced{OutputTypeNames: []string{"string"}, Err: boom}, &fxevent.Replaced{OutputTypeNames: []string{"string"}}}, + {"Decorated", &fxevent.Decorated{OutputTypeNames: []string{"string"}, Err: boom}, &fxevent.Decorated{OutputTypeNames: []string{"string"}}}, + {"Run", &fxevent.Run{Name: "run", Kind: "provide", Err: boom}, &fxevent.Run{Name: "run", Kind: "provide"}}, + {"Invoked", &fxevent.Invoked{FunctionName: "invoke", Err: boom}, &fxevent.Invoked{FunctionName: "invoke"}}, + {"Started", &fxevent.Started{Err: boom}, &fxevent.Started{}}, + {"Stopped", &fxevent.Stopped{Err: boom}, &fxevent.Stopped{}}, + {"RollingBack", &fxevent.RollingBack{StartErr: boom}, nil}, + {"RolledBack", &fxevent.RolledBack{Err: boom}, &fxevent.RolledBack{}}, + {"LoggerInitialized", &fxevent.LoggerInitialized{ConstructorName: "logger", Err: boom}, &fxevent.LoggerInitialized{ConstructorName: "logger"}}, + } { + t.Run(c.name, func(t *testing.T) { + rec := record(t) + NewEventLogger().LogEvent(c.failed) + require.NotEmpty(t, rec.lines()) + for _, l := range rec.lines() { + assert.Equal(t, "error", l.level, "%q", l.text) + } + if c.ok == nil { + return + } + rec = record(t) + NewEventLogger().LogEvent(c.ok) + for _, l := range rec.lines() { + assert.Equal(t, strings.ToLower(LogLevel()), l.level, "%q", l.text) + } + }) + } +} + +// TestEventLogger_AnEventWithNothingToSayWritesNoLine: fx renders no line for +// a provide without outputs, and the logger writes none. +func TestEventLogger_AnEventWithNothingToSayWritesNoLine(t *testing.T) { + boost.Start() + rec := record(t) + NewEventLogger().LogEvent(&fxevent.Provided{}) + NewEventLogger().LogEvent(&fxevent.Invoking{FunctionName: "invoke"}) + assert.Len(t, rec.lines(), 1, "only the invoke has a line: %v", rec.lines()) +} + +// TestLogger_PrintsAtItsLevel: each fx log level writes at its boost level, +// DEBUG for any other value. +func TestLogger_PrintsAtItsLevel(t *testing.T) { + for level, want := range map[string]string{"INFO": "info", "TRACE": "trace", "DEBUG": "debug", "other": "debug"} { + rec := record(t) + (&Logger{level: level}).Printf("line %d", 1) + assert.Equal(t, []entry{{level: want, text: "line 1"}}, rec.lines(), level) + } +} From 754baca6701b38ac946ef4270f98249caec0983b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 10:45:12 -0300 Subject: [PATCH 05/11] fix(inject): key types by import path (#202) Two @Config Options in packages that share a name (one boost package per publisher) collided: the type id was the package name plus the type, so both looked like *boost.Options and the graph refused to build. The id now qualifies every type with its import path, read from the file's imports. A hand-built entry without imports still resolves a qualifier when one package of the entries has that name, so callers that build entries themselves keep working. Provider keeps matching by name and is deprecated in favour of ProviderFor, which the builder now uses. --- builder/newspec.go | 2 +- inject/README.md | 17 ++-- inject/client.go | 4 +- inject/config_test.go | 41 ++++++++ inject/examples/samename/app/app.go | 19 ++++ .../samename/gen/app/newsubjects_module.go | 32 +++++++ inject/examples/samename/gen/module.go | 22 +++++ .../pricechanged/boost/newpublisher_module.go | 29 ++++++ .../pricechanged/boost/options_module.go | 40 ++++++++ .../boost/newpublisher_module.go | 29 ++++++ .../pricereceived/boost/options_module.go | 40 ++++++++ inject/examples/samename/main.go | 28 ++++++ inject/examples/samename/main_test.go | 73 ++++++++++++++ .../publisher/pricechanged/boost/options.go | 9 ++ .../publisher/pricechanged/boost/publisher.go | 14 +++ .../publisher/pricereceived/boost/options.go | 9 ++ .../pricereceived/boost/publisher.go | 14 +++ inject/export_test.go | 36 +++++++ inject/graph.go | 43 ++++++--- inject/implementation.go | 4 +- inject/publisher.go | 2 +- .../samename/app/newservice_module.go.golden | 32 +++++++ inject/testdata/samename/module.go.golden | 22 +++++ .../boost/newpublisher_module.go.golden | 29 ++++++ .../boost/options_module.go.golden | 40 ++++++++ .../boost/newpublisher_module.go.golden | 29 ++++++ .../boost/options_module.go.golden | 40 ++++++++ inject/typeid.go | 96 +++++++++++++++++-- inject/typeid_test.go | 59 ++++++++++-- 29 files changed, 815 insertions(+), 39 deletions(-) create mode 100644 inject/examples/samename/app/app.go create mode 100644 inject/examples/samename/gen/app/newsubjects_module.go create mode 100644 inject/examples/samename/gen/module.go create mode 100644 inject/examples/samename/gen/publisher/pricechanged/boost/newpublisher_module.go create mode 100644 inject/examples/samename/gen/publisher/pricechanged/boost/options_module.go create mode 100644 inject/examples/samename/gen/publisher/pricereceived/boost/newpublisher_module.go create mode 100644 inject/examples/samename/gen/publisher/pricereceived/boost/options_module.go create mode 100644 inject/examples/samename/main.go create mode 100644 inject/examples/samename/main_test.go create mode 100644 inject/examples/samename/publisher/pricechanged/boost/options.go create mode 100644 inject/examples/samename/publisher/pricechanged/boost/publisher.go create mode 100644 inject/examples/samename/publisher/pricereceived/boost/options.go create mode 100644 inject/examples/samename/publisher/pricereceived/boost/publisher.go create mode 100644 inject/testdata/samename/app/newservice_module.go.golden create mode 100644 inject/testdata/samename/module.go.golden create mode 100644 inject/testdata/samename/pricechanged/boost/newpublisher_module.go.golden create mode 100644 inject/testdata/samename/pricechanged/boost/options_module.go.golden create mode 100644 inject/testdata/samename/pricereceived/boost/newpublisher_module.go.golden create mode 100644 inject/testdata/samename/pricereceived/boost/options_module.go.golden diff --git a/builder/newspec.go b/builder/newspec.go index d4a8909e..223f0769 100644 --- a/builder/newspec.go +++ b/builder/newspec.go @@ -348,7 +348,7 @@ func (b *specBuilder) handler(entry annotation.Entry, ann string) (Handler, erro } for _, tp := range []string{"*" + entry.Struct, entry.Struct} { - if comp, _, ok := inject.Provider(b.graph, entry.Package, tp); ok { + if comp, _, ok := inject.ProviderFor(b.graph, entry, tp); ok { if err := b.singleConstructor(comp, ann, declName(entry), "its receiver "+tp); err != nil { return Handler{}, err } diff --git a/inject/README.md b/inject/README.md index 057f16f6..4fb02022 100644 --- a/inject/README.md +++ b/inject/README.md @@ -24,7 +24,9 @@ function plus a root `Module()` that wires the whole application. `index` is the zero-based position of the result or parameter and is always required. A parameter without `@Inject` is linked to a default provider of the same type when one is annotated; otherwise the application must provide -it (for example with `fx.Supply` or another fx module). +it (for example with `fx.Supply` or another fx module). Types are matched by +import path: two packages with the same name (one `boost` package per +publisher, say) declare different types. ```go package greeting @@ -413,7 +415,8 @@ Generators built on top of inject (such as [`builder`](../builder)) reuse: | `GenPath(base, importPath)` / `GenPathUnder(base, sub, importPath)` | directory under the output dir (or its `sub` dir) that mirrors a package: its path in the project, the whole import path for another module | | `ModuleOf(base, entry)` | import and module call (`gen_x_hash.NewXModule()`, `gen_x_hash.OptionsModule()` for a `@Config`) of a component | | `ConfigKey(modulePath, importPath)` / `ConfigEnv(root, segment)` | root config key of a package's `@Config` and the env var of one of its keys | -| `Provider(g, pkg, tp)` | component and result index providing the default value of a type | +| `ProviderFor(g, entry, tp)` | component and result index providing the default value of a type written in the file of `entry` | +| `Provider(g, pkg, tp)` | deprecated: as `ProviderFor`, but matches types by package name | | `Alias(prefix, name, path)` | collision-free import alias (`prefix_name_hash`) | | `Clients(g)` / `Client.Module(base)` | factory clients the generator builds, and the import and module call of one | | `GeneratorOptions.DefaultClients` / `ClientModule(base, id)` | client types (`github.com/nats-io/nats.go.Conn`) whose default client is generated for the caller to wire, and the import and module call of one (`client_nats_hash.DefaultConnModule()`) | @@ -421,9 +424,11 @@ Generators built on top of inject (such as [`builder`](../builder)) reuse: | `ExecuteTemplate(tmpl, name, data)` | executes a template and gofmt's the output | | `WriteGenerated(ctx, dir, files, header)` | writes files and removes stale ones starting with `header` | -See [`examples/simple`](./examples/simple) and -[`examples/publisher`](./examples/publisher): their tests run the generated -application and fail when `gen/` is stale. +See [`examples/simple`](./examples/simple), +[`examples/publisher`](./examples/publisher) and +[`examples/samename`](./examples/samename) (two `@Config Options` in packages +named `boost`): their tests run the generated application and fail when +`gen/` is stale. [`examples/clients`](./examples/clients) injects a NATS conn, a Pub/Sub client, gocql v1 and v2 sessions and a Bigtable client; its test starts the app against an embedded NATS server, the Pub/Sub emulator, a fake Cassandra @@ -432,8 +437,6 @@ client is closed when the app stops. ## Limitations -- Types are matched by package name and type expression, not by import path: - two packages with the same name and type name are seen as the same type. - Each generated `Module()` returns its options only on the first call (`sync.Once`), so a constructor reached by several modules is registered once. Build the fx application once per process. diff --git a/inject/client.go b/inject/client.go index 3261c80f..6ef29354 100644 --- a/inject/client.go +++ b/inject/client.go @@ -261,7 +261,7 @@ func Clients(g *graph.Graph[Component]) ([]Client, error) { continue } - if provider, ok := provided[injectID(comp.Entry.Package, tp, ann)]; ok { + if provider, ok := provided[injectID(comp.Entry, tp, ann)]; ok { if ann.Plugins != "" { return nil, errors.NotValidf("@Inject on %s parameter %d has plugins, but %s is provided by %s: plugins apply only to a client inject builds", key, idx, tp, provider) @@ -363,7 +363,7 @@ func providedValues(g *graph.Graph[Component]) map[string]string { for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Provides) { - id := xid(comp.Entry.Package, comp.Result(idx), comp.Provides[idx]) + id := xid(comp.Entry, comp.Result(idx), comp.Provides[idx]) if _, ok := provided[id]; !ok { provided[id] = key } diff --git a/inject/config_test.go b/inject/config_test.go index ffaf8417..0f24bcdd 100644 --- a/inject/config_test.go +++ b/inject/config_test.go @@ -273,3 +273,44 @@ func TestConfigKeyAndEnv(t *testing.T) { assert.Equal(t, "INTERNAL_PKG_CLIENT_QUOTE_RESTY_BASE__URL", ConfigEnv("internal.pkg.client.quote.resty", "baseUrl")) assert.Equal(t, "STOCK_SDK__GO_TIMEOUT", ConfigEnv("stock.sdkGo", "timeout")) } + +// Two @Config Options in packages that share a name are two types (#202): each +// is keyed by its import path, so both are provided and each reaches its own +// consumers. +func TestGenerator_RenderConfigInSameNamedPackages(t *testing.T) { + received := cfg("pricereceived/boost", "Options", field("Subject", "string", `default:"precos-entrada" desc:"subject"`)) + changed := cfg("pricechanged/boost", "Options", field("Subject", "string", `default:"precos-alteracao" desc:"subject"`)) + newReceived := at("pricereceived/boost", "NewPublisher", []string{"*Options"}, []string{"*Publisher"}, provide("index=0")) + newChanged := at("pricechanged/boost", "NewPublisher", []string{"*Options"}, []string{"*Publisher"}, provide("index=0")) + service := at("app", "NewService", []string{"*received.Publisher", "*changed.Publisher"}, []string{"*Service"}, provide("index=0")) + service.Imports = map[string]string{ + "received": testModule + "/pricereceived/boost", + "changed": testModule + "/pricechanged/boost", + } + + g := newTestGraph(t, []annotation.Entry{received, changed, newReceived, newChanged, service}) + + assert.Equal(t, []string{testModule + "/pricereceived/boost.NewPublisher"}, + edgesOf(g.Edges[testModule+"/pricereceived/boost.Options"]), "each Options reaches only its package") + assert.Equal(t, []string{testModule + "/pricechanged/boost.NewPublisher"}, + edgesOf(g.Edges[testModule+"/pricechanged/boost.Options"])) + assert.Equal(t, []string{testModule + "/app.NewService"}, + edgesOf(g.Edges[testModule+"/pricereceived/boost.NewPublisher"]), "an aliased import resolves by path") + assert.Equal(t, []string{testModule + "/app.NewService"}, + edgesOf(g.Edges[testModule+"/pricechanged/boost.NewPublisher"])) + + files, err := NewGenerator(g, testOptions("/mod")).Render(context.Background()) + require.NoError(t, err) + + for _, name := range []string{ + "pricereceived/boost/options_module.go", + "pricereceived/boost/newpublisher_module.go", + "pricechanged/boost/options_module.go", + "pricechanged/boost/newpublisher_module.go", + "app/newservice_module.go", + "module.go", + } { + require.Contains(t, files, name) + golden(t, filepath.Join("testdata", "samename", name+".golden"), files[name]) + } +} diff --git a/inject/examples/samename/app/app.go b/inject/examples/samename/app/app.go new file mode 100644 index 00000000..a0676312 --- /dev/null +++ b/inject/examples/samename/app/app.go @@ -0,0 +1,19 @@ +// Package app consumes the publishers of two packages named boost. +package app + +import ( + changed "github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost" + received "github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost" +) + +// Subjects holds the subject each publisher sends to. +type Subjects struct { + Received string + Changed string +} + +// NewSubjects reads the subject of each publisher. +// @Provide(index=0) +func NewSubjects(r *received.Publisher, c *changed.Publisher) *Subjects { + return &Subjects{Received: r.Subject, Changed: c.Subject} +} diff --git a/inject/examples/samename/gen/app/newsubjects_module.go b/inject/examples/samename/gen/app/newsubjects_module.go new file mode 100644 index 00000000..4d681d99 --- /dev/null +++ b/inject/examples/samename/gen/app/newsubjects_module.go @@ -0,0 +1,32 @@ +// Code generated by inject; DO NOT EDIT. + +package app + +import ( + "sync" + + "go.uber.org/fx" + + src_app_b60dda6a "github.com/xgodev/boost/inject/examples/samename/app" + gen_boost_d9c1e4eb "github.com/xgodev/boost/inject/examples/samename/gen/publisher/pricechanged/boost" + gen_boost_92081d3e "github.com/xgodev/boost/inject/examples/samename/gen/publisher/pricereceived/boost" +) + +var NewSubjectsOnce sync.Once + +// NewSubjectsModule registers github.com/xgodev/boost/inject/examples/samename/app.NewSubjects and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewSubjectsModule() fx.Option { + options := fx.Options() + + NewSubjectsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/inject/examples/samename/app.NewSubjects", + gen_boost_d9c1e4eb.NewPublisherModule(), + gen_boost_92081d3e.NewPublisherModule(), + fx.Provide(src_app_b60dda6a.NewSubjects), + ) + }) + + return options +} diff --git a/inject/examples/samename/gen/module.go b/inject/examples/samename/gen/module.go new file mode 100644 index 00000000..5ad8369b --- /dev/null +++ b/inject/examples/samename/gen/module.go @@ -0,0 +1,22 @@ +// Code generated by inject; DO NOT EDIT. + +package gen + +import ( + "go.uber.org/fx" + + gen_app_b60dda6a "github.com/xgodev/boost/inject/examples/samename/gen/app" + gen_boost_d9c1e4eb "github.com/xgodev/boost/inject/examples/samename/gen/publisher/pricechanged/boost" + gen_boost_92081d3e "github.com/xgodev/boost/inject/examples/samename/gen/publisher/pricereceived/boost" +) + +// Module registers every module generated by inject. +func Module() fx.Option { + return fx.Options( + gen_app_b60dda6a.NewSubjectsModule(), + gen_boost_d9c1e4eb.NewPublisherModule(), + gen_boost_d9c1e4eb.OptionsModule(), + gen_boost_92081d3e.NewPublisherModule(), + gen_boost_92081d3e.OptionsModule(), + ) +} diff --git a/inject/examples/samename/gen/publisher/pricechanged/boost/newpublisher_module.go b/inject/examples/samename/gen/publisher/pricechanged/boost/newpublisher_module.go new file mode 100644 index 00000000..02aba8e1 --- /dev/null +++ b/inject/examples/samename/gen/publisher/pricechanged/boost/newpublisher_module.go @@ -0,0 +1,29 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "go.uber.org/fx" + + src_boost_d9c1e4eb "github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost" +) + +var NewPublisherOnce sync.Once + +// NewPublisherModule registers github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost.NewPublisher and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewPublisherModule() fx.Option { + options := fx.Options() + + NewPublisherOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost.NewPublisher", + OptionsModule(), + fx.Provide(src_boost_d9c1e4eb.NewPublisher), + ) + }) + + return options +} diff --git a/inject/examples/samename/gen/publisher/pricechanged/boost/options_module.go b/inject/examples/samename/gen/publisher/pricechanged/boost/options_module.go new file mode 100644 index 00000000..7a307951 --- /dev/null +++ b/inject/examples/samename/gen/publisher/pricechanged/boost/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_boost_d9c1e4eb "github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost.Options. +const optionsRoot = "inject.examples.samename.publisher.pricechanged.boost" + +func init() { + // env INJECT_EXAMPLES_SAMENAME_PUBLISHER_PRICECHANGED_BOOST_SUBJECT + config.Add(optionsRoot+".subject", "precos-alteracao", "subject the price changed events go to") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost, +// filled from the config keys under inject.examples.samename.publisher.pricechanged.boost. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/inject/examples/samename/publisher/pricechanged/boost.Options", + fx.Provide(func() (*src_boost_d9c1e4eb.Options, error) { + return config.NewOptionsWithPath[src_boost_d9c1e4eb.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/inject/examples/samename/gen/publisher/pricereceived/boost/newpublisher_module.go b/inject/examples/samename/gen/publisher/pricereceived/boost/newpublisher_module.go new file mode 100644 index 00000000..684fe637 --- /dev/null +++ b/inject/examples/samename/gen/publisher/pricereceived/boost/newpublisher_module.go @@ -0,0 +1,29 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "go.uber.org/fx" + + src_boost_92081d3e "github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost" +) + +var NewPublisherOnce sync.Once + +// NewPublisherModule registers github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost.NewPublisher and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewPublisherModule() fx.Option { + options := fx.Options() + + NewPublisherOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost.NewPublisher", + OptionsModule(), + fx.Provide(src_boost_92081d3e.NewPublisher), + ) + }) + + return options +} diff --git a/inject/examples/samename/gen/publisher/pricereceived/boost/options_module.go b/inject/examples/samename/gen/publisher/pricereceived/boost/options_module.go new file mode 100644 index 00000000..a1cfc87b --- /dev/null +++ b/inject/examples/samename/gen/publisher/pricereceived/boost/options_module.go @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_boost_92081d3e "github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost" +) + +// optionsRoot is the config key holding github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost.Options. +const optionsRoot = "inject.examples.samename.publisher.pricereceived.boost" + +func init() { + // env INJECT_EXAMPLES_SAMENAME_PUBLISHER_PRICERECEIVED_BOOST_SUBJECT + config.Add(optionsRoot+".subject", "precos-entrada", "subject the price received events go to") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost, +// filled from the config keys under inject.examples.samename.publisher.pricereceived.boost. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("github.com/xgodev/boost/inject/examples/samename/publisher/pricereceived/boost.Options", + fx.Provide(func() (*src_boost_92081d3e.Options, error) { + return config.NewOptionsWithPath[src_boost_92081d3e.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/inject/examples/samename/main.go b/inject/examples/samename/main.go new file mode 100644 index 00000000..f23da36b --- /dev/null +++ b/inject/examples/samename/main.go @@ -0,0 +1,28 @@ +// Command samename wires two @Config Options declared in packages that share +// a name: inject tells their types apart by import path. +// +// Regenerate the modules in gen/ with: +// +// go generate ./inject/examples/samename +package main + +//go:generate go run github.com/xgodev/boost/inject/cmd/inject --boost.inject.output=gen + +import ( + "github.com/xgodev/boost" + "github.com/xgodev/boost/inject/examples/samename/app" + "github.com/xgodev/boost/inject/examples/samename/gen" + "github.com/xgodev/boost/wrapper/log" + "go.uber.org/fx" +) + +func main() { + boost.Start() + + var subjects *app.Subjects + if err := fx.New(gen.Module(), fx.Populate(&subjects), fx.NopLogger).Err(); err != nil { + log.Fatalf("%v", err) + } + + log.Infof("price received -> %s, price changed -> %s", subjects.Received, subjects.Changed) +} diff --git a/inject/examples/samename/main_test.go b/inject/examples/samename/main_test.go new file mode 100644 index 00000000..04f6340e --- /dev/null +++ b/inject/examples/samename/main_test.go @@ -0,0 +1,73 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/inject" + "github.com/xgodev/boost/inject/examples/samename/app" + "github.com/xgodev/boost/inject/examples/samename/gen" + "go.uber.org/fx" +) + +// Each boost package gets its own Options: the two types share a package +// name but not an import path (#202). +func TestGeneratedModuleWiresTheApp(t *testing.T) { + boost.Start() // loads the config keys the generated modules registered + + var subjects *app.Subjects + require.NoError(t, fx.New(gen.Module(), fx.Populate(&subjects), fx.NopLogger).Err()) + + assert.Equal(t, &app.Subjects{Received: "precos-entrada", Changed: "precos-alteracao"}, subjects) +} + +// TestGeneratedCodeIsUpToDate fails when gen/ differs from what the +// generator produces for the annotations in this example. Run go generate. +func TestGeneratedCodeIsUpToDate(t *testing.T) { + ctx := context.Background() + + modulePath, moduleDir, err := inject.FindModule(".") + require.NoError(t, err) + + entries, err := inject.CollectEntries(".") + require.NoError(t, err) + + g, err := inject.NewGraphFromEntries(ctx, entries) + require.NoError(t, err) + + out, err := filepath.Abs("gen") + require.NoError(t, err) + + files, err := inject.NewGenerator(g, inject.GeneratorOptions{ + ModulePath: modulePath, + ModuleDir: moduleDir, + OutputDir: out, + }).Render(ctx) + require.NoError(t, err) + + onDisk := map[string]string{} + require.NoError(t, filepath.WalkDir(out, func(p string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + rel, err := filepath.Rel(out, p) + if err != nil { + return err + } + content, err := os.ReadFile(p) + onDisk[filepath.ToSlash(rel)] = string(content) + return err + })) + + const hint = "gen/ is stale: run go generate ./inject/examples/samename" + for name, src := range files { + assert.Equal(t, string(src), onDisk[name], "%s: %s", name, hint) + delete(onDisk, name) + } + assert.Empty(t, onDisk, "files not produced by the generator: %s", hint) +} diff --git a/inject/examples/samename/publisher/pricechanged/boost/options.go b/inject/examples/samename/publisher/pricechanged/boost/options.go new file mode 100644 index 00000000..b447e24c --- /dev/null +++ b/inject/examples/samename/publisher/pricechanged/boost/options.go @@ -0,0 +1,9 @@ +package boost + +// Options configures the publisher of price changed events. inject generates +// its config keys (inject.examples.samename.publisher.pricechanged.boost.*) +// and provides *Options, apart from the Options of the other boost package. +// @Config +type Options struct { + Subject string `default:"precos-alteracao" desc:"subject the price changed events go to"` +} diff --git a/inject/examples/samename/publisher/pricechanged/boost/publisher.go b/inject/examples/samename/publisher/pricechanged/boost/publisher.go new file mode 100644 index 00000000..af19d1e4 --- /dev/null +++ b/inject/examples/samename/publisher/pricechanged/boost/publisher.go @@ -0,0 +1,14 @@ +// Package boost publishes price changed events. It shares its name with the +// package of the other event, as one package per event does. +package boost + +// Publisher sends price changed events to its subject. +type Publisher struct { + Subject string +} + +// NewPublisher builds the publisher from this package's Options. +// @Provide(index=0) +func NewPublisher(options *Options) *Publisher { + return &Publisher{Subject: options.Subject} +} diff --git a/inject/examples/samename/publisher/pricereceived/boost/options.go b/inject/examples/samename/publisher/pricereceived/boost/options.go new file mode 100644 index 00000000..d8498567 --- /dev/null +++ b/inject/examples/samename/publisher/pricereceived/boost/options.go @@ -0,0 +1,9 @@ +package boost + +// Options configures the publisher of price received events. inject generates +// its config keys (inject.examples.samename.publisher.pricereceived.boost.*) +// and provides *Options, apart from the Options of the other boost package. +// @Config +type Options struct { + Subject string `default:"precos-entrada" desc:"subject the price received events go to"` +} diff --git a/inject/examples/samename/publisher/pricereceived/boost/publisher.go b/inject/examples/samename/publisher/pricereceived/boost/publisher.go new file mode 100644 index 00000000..ab05a6a0 --- /dev/null +++ b/inject/examples/samename/publisher/pricereceived/boost/publisher.go @@ -0,0 +1,14 @@ +// Package boost publishes price received events. It shares its name with the +// package of the other event, as one package per event does. +package boost + +// Publisher sends price received events to its subject. +type Publisher struct { + Subject string +} + +// NewPublisher builds the publisher from this package's Options. +// @Provide(index=0) +func NewPublisher(options *Options) *Publisher { + return &Publisher{Subject: options.Subject} +} diff --git a/inject/export_test.go b/inject/export_test.go index 3517cc47..50fda311 100644 --- a/inject/export_test.go +++ b/inject/export_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/xgodev/boost/annotation" "github.com/xgodev/boost/model/errors" ) @@ -114,6 +115,41 @@ func TestProvider(t *testing.T) { } } +func TestProviderFor(t *testing.T) { + received := at("pricereceived/boost", "NewPublisher", nil, []string{"*Publisher"}, provide("index=0")) + changed := at("pricechanged/boost", "NewPublisher", nil, []string{"*Publisher"}, provide("index=0")) + g := newTestGraph(t, []annotation.Entry{received, changed}) + + consumer := at("app", "Run", nil, nil) + consumer.Imports = map[string]string{"boost": testModule + "/pricechanged/boost"} + + testCases := []struct { + name string + entry annotation.Entry + tp string + wantKey string + }{ + {name: "local type in one of two same-named packages", entry: received, tp: "*Publisher", + wantKey: testModule + "/pricereceived/boost.NewPublisher"}, + {name: "the other one", entry: changed, tp: "*Publisher", + wantKey: testModule + "/pricechanged/boost.NewPublisher"}, + {name: "qualified, resolved by the file's import", entry: consumer, tp: "*boost.Publisher", + wantKey: testModule + "/pricechanged/boost.NewPublisher"}, + {name: "unknown type", entry: consumer, tp: "*Missing"}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + comp, idx, ok := ProviderFor(g, tc.entry, tc.tp) + require.Equal(t, tc.wantKey != "", ok) + if ok { + assert.Equal(t, tc.wantKey, comp.Key()) + assert.Equal(t, 0, idx) + } + }) + } +} + func TestExecuteTemplate(t *testing.T) { tmpl := template.Must(template.New("t").Parse(`{{define "ok"}}package {{.}} func A( ) {}{{end}}{{define "bad"}}package {{.}} func{{end}}{{define "fail"}}{{.Missing}}{{end}}`)) diff --git a/inject/graph.go b/inject/graph.go index c1e94503..c6d04045 100644 --- a/inject/graph.go +++ b/inject/graph.go @@ -35,6 +35,7 @@ import ( // @Inject has no provider. func NewGraphFromEntries(ctx context.Context, entries []annotation.Entry) (*graph.Graph[Component], error) { g := graph.NewGraph[Component]() + entries = withImports(entries) providers, err := addComponents(g, entries) if err != nil { @@ -85,7 +86,7 @@ func addComponents(g *graph.Graph[Component], entries []annotation.Entry) (map[s for _, idx := range sortedIndexes(comp.Provides) { ann := comp.Provides[idx] - id := xid(entry.Package, comp.Result(idx), ann) + id := xid(entry, comp.Result(idx), ann) providers[id] = append(providers[id], key) groups[id] = ann.Group != "" } @@ -136,7 +137,7 @@ func linkComponents(ctx context.Context, g *graph.Graph[Component], providers ma comp := g.Vertices[key].Value for i, param := range comp.Entry.Func.Parameters { ann, injected := comp.Injects[i] - id := injectID(comp.Entry.Package, param.Type, ann) + id := injectID(comp.Entry, param.Type, ann) keys := providers[id] if _, client := clientFactory(comp.Entry, param.Type); len(keys) == 0 && injected && client && ann.Group == "" { @@ -170,13 +171,32 @@ func linkComponents(ctx context.Context, g *graph.Graph[Component], providers ma // no group) of type tp, written as in package pkg ("*Handler" in package // orders and "*orders.Handler" elsewhere are the same type), and the index of // that result. +// +// Deprecated: Provider matches types by package name, so it cannot tell two +// packages with the same name apart; use ProviderFor. func Provider(g *graph.Graph[Component], pkg, tp string) (Component, int, bool) { - want := xid(pkg, tp, Annotation{}) + none := Annotation{} + want := qualifyName(pkg, tp) + "_" + none.ID() + return findProvider(g, func(comp Component, idx int, ann Annotation) bool { + return qualifyName(comp.Entry.Package, comp.Result(idx))+"_"+ann.ID() == want + }) +} + +// ProviderFor returns the component that provides the default value (no +// name, no group) of type tp, written in the file of entry, and the index of +// that result. Types are matched by import path. +func ProviderFor(g *graph.Graph[Component], entry annotation.Entry, tp string) (Component, int, bool) { + want := xid(entry, tp, Annotation{}) + return findProvider(g, func(comp Component, idx int, ann Annotation) bool { + return xid(comp.Entry, comp.Result(idx), ann) == want + }) +} + +func findProvider(g *graph.Graph[Component], match func(Component, int, Annotation) bool) (Component, int, bool) { for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Provides) { - ann := comp.Provides[idx] - if xid(comp.Entry.Package, comp.Result(idx), ann) == want { + if match(comp, idx, comp.Provides[idx]) { return comp, idx, true } } @@ -407,21 +427,22 @@ func declName(entry annotation.Entry) string { return entry.Func.Name } -// xid identifies a provided or injected value by its qualified type and -// annotation (default, named or grouped). -func xid(pkg, tp string, ann Annotation) string { - return fmt.Sprintf("%s_%s", qualifyType(pkg, tp), ann.ID()) +// xid identifies a provided or injected value by its type, qualified by +// import path in the file of entry, and its annotation (default, named or +// grouped). +func xid(entry annotation.Entry, tp string, ann Annotation) string { + return fmt.Sprintf("%s_%s", qualifyType(entry, tp), ann.ID()) } // injectID returns the id a parameter depends on. A group parameter `[]T` // depends on the providers of T in that group. -func injectID(pkg, tp string, ann Annotation) string { +func injectID(entry annotation.Entry, tp string, ann Annotation) string { if ann.Group != "" { if elem, ok := sliceElem(tp); ok { tp = elem } } - return xid(pkg, tp, ann) + return xid(entry, tp, ann) } func sortedIndexes(m map[int]Annotation) []int { diff --git a/inject/implementation.go b/inject/implementation.go index 3929ada5..bff8351f 100644 --- a/inject/implementation.go +++ b/inject/implementation.go @@ -36,7 +36,7 @@ func Implementations(g *graph.Graph[Component]) ([]Implementation, error) { if ann.Group != "" { continue } - id := xid(comp.Entry.Package, comp.Result(idx), ann) + id := xid(comp.Entry, comp.Result(idx), ann) providers[id] = append(providers[id], comp) } } @@ -183,7 +183,7 @@ func checkConsumers(g *graph.Graph[Component], sets map[string]Implementation) e for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for i, param := range comp.Entry.Func.Parameters { - set, ok := sets[injectID(comp.Entry.Package, param.Type, comp.Injects[i])] + set, ok := sets[injectID(comp.Entry, param.Type, comp.Injects[i])] if !ok { continue } diff --git a/inject/publisher.go b/inject/publisher.go index 2c937851..11e0b7bf 100644 --- a/inject/publisher.go +++ b/inject/publisher.go @@ -75,7 +75,7 @@ func PublisherOf(g *graph.Graph[Component]) Publisher { if ann.Name != "" || ann.Group != "" || !isPublisher(comp.Entry, tp) { continue } - if _, ok := provided[injectID(comp.Entry.Package, tp, ann)]; ok { + if _, ok := provided[injectID(comp.Entry, tp, ann)]; ok { continue } if !slices.Contains(p.Consumers, key) { diff --git a/inject/testdata/samename/app/newservice_module.go.golden b/inject/testdata/samename/app/newservice_module.go.golden new file mode 100644 index 00000000..66c8fe6d --- /dev/null +++ b/inject/testdata/samename/app/newservice_module.go.golden @@ -0,0 +1,32 @@ +// Code generated by inject; DO NOT EDIT. + +package app + +import ( + "sync" + + "go.uber.org/fx" + + src_app_66aa3341 "example.com/app/app" + gen_boost_1981e1a8 "example.com/app/gen/pricechanged/boost" + gen_boost_4d3911e9 "example.com/app/gen/pricereceived/boost" +) + +var NewServiceOnce sync.Once + +// NewServiceModule registers example.com/app/app.NewService and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewServiceModule() fx.Option { + options := fx.Options() + + NewServiceOnce.Do(func() { + options = fx.Module("example.com/app/app.NewService", + gen_boost_1981e1a8.NewPublisherModule(), + gen_boost_4d3911e9.NewPublisherModule(), + fx.Provide(src_app_66aa3341.NewService), + ) + }) + + return options +} diff --git a/inject/testdata/samename/module.go.golden b/inject/testdata/samename/module.go.golden new file mode 100644 index 00000000..57c3281a --- /dev/null +++ b/inject/testdata/samename/module.go.golden @@ -0,0 +1,22 @@ +// Code generated by inject; DO NOT EDIT. + +package gen + +import ( + "go.uber.org/fx" + + gen_app_66aa3341 "example.com/app/gen/app" + gen_boost_1981e1a8 "example.com/app/gen/pricechanged/boost" + gen_boost_4d3911e9 "example.com/app/gen/pricereceived/boost" +) + +// Module registers every module generated by inject. +func Module() fx.Option { + return fx.Options( + gen_app_66aa3341.NewServiceModule(), + gen_boost_1981e1a8.NewPublisherModule(), + gen_boost_1981e1a8.OptionsModule(), + gen_boost_4d3911e9.NewPublisherModule(), + gen_boost_4d3911e9.OptionsModule(), + ) +} diff --git a/inject/testdata/samename/pricechanged/boost/newpublisher_module.go.golden b/inject/testdata/samename/pricechanged/boost/newpublisher_module.go.golden new file mode 100644 index 00000000..fc1f0bd0 --- /dev/null +++ b/inject/testdata/samename/pricechanged/boost/newpublisher_module.go.golden @@ -0,0 +1,29 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "go.uber.org/fx" + + src_boost_1981e1a8 "example.com/app/pricechanged/boost" +) + +var NewPublisherOnce sync.Once + +// NewPublisherModule registers example.com/app/pricechanged/boost.NewPublisher and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewPublisherModule() fx.Option { + options := fx.Options() + + NewPublisherOnce.Do(func() { + options = fx.Module("example.com/app/pricechanged/boost.NewPublisher", + OptionsModule(), + fx.Provide(src_boost_1981e1a8.NewPublisher), + ) + }) + + return options +} diff --git a/inject/testdata/samename/pricechanged/boost/options_module.go.golden b/inject/testdata/samename/pricechanged/boost/options_module.go.golden new file mode 100644 index 00000000..1a3dd9f1 --- /dev/null +++ b/inject/testdata/samename/pricechanged/boost/options_module.go.golden @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_boost_1981e1a8 "example.com/app/pricechanged/boost" +) + +// optionsRoot is the config key holding example.com/app/pricechanged/boost.Options. +const optionsRoot = "pricechanged.boost" + +func init() { + // env PRICECHANGED_BOOST_SUBJECT + config.Add(optionsRoot+".subject", "precos-alteracao", "subject") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of example.com/app/pricechanged/boost, +// filled from the config keys under pricechanged.boost. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("example.com/app/pricechanged/boost.Options", + fx.Provide(func() (*src_boost_1981e1a8.Options, error) { + return config.NewOptionsWithPath[src_boost_1981e1a8.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/inject/testdata/samename/pricereceived/boost/newpublisher_module.go.golden b/inject/testdata/samename/pricereceived/boost/newpublisher_module.go.golden new file mode 100644 index 00000000..146e7064 --- /dev/null +++ b/inject/testdata/samename/pricereceived/boost/newpublisher_module.go.golden @@ -0,0 +1,29 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "go.uber.org/fx" + + src_boost_4d3911e9 "example.com/app/pricereceived/boost" +) + +var NewPublisherOnce sync.Once + +// NewPublisherModule registers example.com/app/pricereceived/boost.NewPublisher and the modules it depends on. +// It returns an empty option after the first call, so a constructor shared by +// several modules is registered only once. +func NewPublisherModule() fx.Option { + options := fx.Options() + + NewPublisherOnce.Do(func() { + options = fx.Module("example.com/app/pricereceived/boost.NewPublisher", + OptionsModule(), + fx.Provide(src_boost_4d3911e9.NewPublisher), + ) + }) + + return options +} diff --git a/inject/testdata/samename/pricereceived/boost/options_module.go.golden b/inject/testdata/samename/pricereceived/boost/options_module.go.golden new file mode 100644 index 00000000..693ea261 --- /dev/null +++ b/inject/testdata/samename/pricereceived/boost/options_module.go.golden @@ -0,0 +1,40 @@ +// Code generated by inject; DO NOT EDIT. + +package boost + +import ( + "sync" + + "github.com/xgodev/boost/wrapper/config" + "go.uber.org/fx" + + src_boost_4d3911e9 "example.com/app/pricereceived/boost" +) + +// optionsRoot is the config key holding example.com/app/pricereceived/boost.Options. +const optionsRoot = "pricereceived.boost" + +func init() { + // env PRICERECEIVED_BOOST_SUBJECT + config.Add(optionsRoot+".subject", "precos-entrada", "subject") +} + +var optionsOnce sync.Once + +// OptionsModule provides the Options of example.com/app/pricereceived/boost, +// filled from the config keys under pricereceived.boost. +// It returns an empty option after the first call, so every consumer can +// include it. +func OptionsModule() fx.Option { + options := fx.Options() + + optionsOnce.Do(func() { + options = fx.Module("example.com/app/pricereceived/boost.Options", + fx.Provide(func() (*src_boost_4d3911e9.Options, error) { + return config.NewOptionsWithPath[src_boost_4d3911e9.Options](optionsRoot) + }), + ) + }) + + return options +} diff --git a/inject/typeid.go b/inject/typeid.go index ddb4fff2..288717fb 100644 --- a/inject/typeid.go +++ b/inject/typeid.go @@ -5,14 +5,33 @@ import ( "go/parser" "go/types" + "github.com/xgodev/boost/annotation" "golang.org/x/tools/go/ast/astutil" ) -// qualifyType rewrites the type expression tp, written inside package pkg, -// so every package-local type name carries the package qualifier. "*Loren" -// declared in package simple and "*simple.Loren" seen from another package -// end up with the same id. Predeclared types (string, error, ...) are kept. -func qualifyType(pkg, tp string) string { +// qualifyType rewrites the type expression tp, written in the file of entry, +// so every type name carries the import path of its package. "*Loren" +// declared in package example.com/app/simple and "*simple.Loren" seen from a +// file importing that package end up with the same id, and two packages that +// share a name are told apart. A qualifier the file does not import is kept as +// written. Predeclared types (string, error, ...) are kept. +func qualifyType(entry annotation.Entry, tp string) string { + return rewriteType(tp, func(name string) string { + if path, ok := entry.Imports[name]; ok { + return path + } + return name + }, entry.Path) +} + +// qualifyName is qualifyType by package name: the id Provider matches on. +func qualifyName(pkg, tp string) string { + return rewriteType(tp, func(name string) string { return name }, pkg) +} + +// rewriteType qualifies every type name of tp: a qualified one (pkg.Type) by +// what qualifier returns for pkg, a local one by local. +func rewriteType(tp string, qualifier func(string) string, local string) string { expr, err := parser.ParseExpr(tp) if err != nil { return tp @@ -21,14 +40,16 @@ func qualifyType(pkg, tp string) string { out := astutil.Apply(expr, func(c *astutil.Cursor) bool { switch n := c.Node().(type) { case *ast.SelectorExpr: - // already qualified (pkg.Type) + if pkg, ok := n.X.(*ast.Ident); ok { + c.Replace(&ast.SelectorExpr{X: ast.NewIdent(qualifier(pkg.Name)), Sel: n.Sel}) + } return false case *ast.Ident: // parameter, result and struct field names are not types if c.Name() == "Names" || isPredeclaredType(n.Name) { return false } - c.Replace(&ast.SelectorExpr{X: ast.NewIdent(pkg), Sel: ast.NewIdent(n.Name)}) + c.Replace(&ast.SelectorExpr{X: ast.NewIdent(local), Sel: ast.NewIdent(n.Name)}) return false } return true @@ -55,3 +76,64 @@ func isPredeclaredType(name string) bool { _, ok := types.Universe.Lookup(name).(*types.TypeName) return ok } + +// withImports returns entries with the imports a hand-built entry leaves out: +// a qualifier its function types use that is not in Imports resolves to the +// package of the entries with that name, when only one has it. The collector +// fills every import of a file, so its entries come back unchanged. +func withImports(entries []annotation.Entry) []annotation.Entry { + paths := map[string]map[string]bool{} // package name -> import paths + for _, entry := range entries { + if paths[entry.Package] == nil { + paths[entry.Package] = map[string]bool{} + } + paths[entry.Package][entry.Path] = true + } + + out := make([]annotation.Entry, len(entries)) + for i, entry := range entries { + copied := false + for _, name := range qualifiers(entry) { + if _, ok := entry.Imports[name]; ok || len(paths[name]) != 1 { + continue + } + if !copied { + // entries of a file share the map + imports := make(map[string]string, len(entry.Imports)+1) + for k, v := range entry.Imports { + imports[k] = v + } + entry.Imports, copied = imports, true + } + for p := range paths[name] { + entry.Imports[name] = p + } + } + out[i] = entry + } + return out +} + +// qualifiers returns the package qualifiers used by the parameter and result +// types of entry. +func qualifiers(entry annotation.Entry) []string { + var names []string + for _, list := range [][]annotation.EntryFuncType{entry.Func.Parameters, entry.Func.Results} { + for _, param := range list { + expr, err := parser.ParseExpr(param.Type) + if err != nil { + continue + } + ast.Inspect(expr, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok { + names = append(names, pkg.Name) + } + return false + } + return true + }) + } + } + return names +} diff --git a/inject/typeid_test.go b/inject/typeid_test.go index e85304f6..d7ea29db 100644 --- a/inject/typeid_test.go +++ b/inject/typeid_test.go @@ -4,28 +4,48 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/xgodev/boost/annotation" ) func TestQualifyType(t *testing.T) { + entry := annotation.Entry{ + Path: "example.com/app/simple", + Package: "simple", + Imports: map[string]string{"annotated": "example.com/lib/annotated", "context": "context"}, + } tests := []struct { tp string want string }{ - {"*Loren", "*simple.Loren"}, - {"Loren", "simple.Loren"}, - {"*annotated.Loren", "*annotated.Loren"}, + {"*Loren", "*example.com/app/simple.Loren"}, + {"Loren", "example.com/app/simple.Loren"}, + {"*annotated.Loren", "*example.com/lib/annotated.Loren"}, + {"*unknown.Loren", "*unknown.Loren"}, {"string", "string"}, {"error", "error"}, - {"[]*Loren", "[]*simple.Loren"}, - {"map[string]Loren", "map[string]simple.Loren"}, - {"func(ctx context.Context, l *Loren) error", "func(ctx context.Context, l *simple.Loren) error"}, - {"chan<- Loren", "chan<- simple.Loren"}, + {"[]*Loren", "[]*example.com/app/simple.Loren"}, + {"map[string]Loren", "map[string]example.com/app/simple.Loren"}, + {"func(ctx context.Context, l *Loren) error", "func(ctx context.Context, l *example.com/app/simple.Loren) error"}, + {"chan<- Loren", "chan<- example.com/app/simple.Loren"}, + {"not a type(", "not a type("}, } for _, tt := range tests { t.Run(tt.tp, func(t *testing.T) { - assert.Equal(t, tt.want, qualifyType("simple", tt.tp)) + assert.Equal(t, tt.want, qualifyType(entry, tt.tp)) }) } + + t.Run("two packages with the same name are two types", func(t *testing.T) { + other := entry + other.Path = "example.com/app/other/simple" + assert.NotEqual(t, qualifyType(entry, "*Loren"), qualifyType(other, "*Loren")) + }) +} + +func TestQualifyName(t *testing.T) { + assert.Equal(t, "*simple.Loren", qualifyName("simple", "*Loren")) + assert.Equal(t, "*annotated.Loren", qualifyName("simple", "*annotated.Loren")) + assert.Equal(t, "map[string]simple.Loren", qualifyName("simple", "map[string]Loren")) } func TestSliceElem(t *testing.T) { @@ -48,3 +68,26 @@ func TestAnnotationTag(t *testing.T) { assert.Equal(t, "grouped_handlers", (&Annotation{Group: "handlers"}).ID()) assert.Equal(t, "default", (&Annotation{}).ID()) } + +func TestWithImports(t *testing.T) { + shared := map[string]string{"context": "context"} + consumer := annotation.Entry{ + Path: "example.com/app/app", Package: "app", Imports: shared, + Func: annotation.EntryFunc{Parameters: []annotation.EntryFuncType{ + {Type: "*quote.Client"}, {Type: "context.Context"}, {Type: "*boost.Options"}, {Type: "*nowhere.T"}, {Type: "("}, + }, Results: []annotation.EntryFuncType{{Type: "map[string]*quote.Client"}}}, + } + entries := []annotation.Entry{ + consumer, + {Path: "example.com/app/quote", Package: "quote"}, + {Path: "example.com/app/a/boost", Package: "boost"}, + {Path: "example.com/app/b/boost", Package: "boost"}, + } + + got := withImports(entries) + + assert.Equal(t, map[string]string{"context": "context", "quote": "example.com/app/quote"}, got[0].Imports, + "a qualifier left out resolves to the one package with that name; two candidates or none stay unresolved") + assert.Equal(t, map[string]string{"context": "context"}, shared, "the map the file's entries share is not changed") + assert.Nil(t, got[1].Imports, "an entry with nothing to resolve is left as is") +} From b74ce3a81b97389b156057d37cf849eb96041fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 10:53:52 -0300 Subject: [PATCH 06/11] fix(bootstrap/function): run the only adapter without arguments (#203) Run prepended boost.bootstrap.function.default only after checking the args against a root command that had no subcommands yet, so with the key empty a generated app (which wires the one adapter config chose) ran cobra with no command and printed the usage. Run now picks the named command, else the default key, else the only adapter, and fails with NotValid when there is nothing to run or the choice is ambiguous, so the boot exits non-zero instead of idling on a help text. --- bootstrap/function/function.go | 50 +++++++++- bootstrap/function/function_test.go | 92 +++++++++++++++++++ builder/README.md | 13 ++- .../examples/simple/cmd/worker/main_test.go | 81 +++++++++++++++- fx/modules/bootstrap/function/README.md | 10 ++ 5 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 bootstrap/function/function_test.go diff --git a/bootstrap/function/function.go b/bootstrap/function/function.go index b7a7e37e..ebb75bcb 100644 --- a/bootstrap/function/function.go +++ b/bootstrap/function/function.go @@ -3,6 +3,7 @@ package function import ( "context" "os" + "strings" "github.com/cloudevents/sdk-go/v2/event" "github.com/xgodev/boost/extra/middleware" @@ -32,10 +33,19 @@ func New[T any](m ...middleware.AnyErrorMiddleware[T]) (*Function[T], error) { return &Function[T]{middlewares: m}, nil } +// Run runs fn through the adapter command the first argument names. With no +// command named, it runs the one boost.bootstrap.function.default names, or +// the only adapter given: an app wires the one adapter config chose, so it +// runs without arguments. It fails with NotValid when there is no adapter, +// or more than one and none is chosen, instead of printing the usage. func (f *Function[T]) Run(ctx context.Context, fn Handler[T], c ...CmdFunc[T]) error { // TODO: github.com/alecthomas/kong + if len(c) == 0 { + return errors.NotValidf("the function has no adapter to run it") + } + var cmds []*co.Command wrp := middleware.NewAnyErrorWrapper[T](ctx, "bootstrap", f.middlewares...) @@ -49,13 +59,43 @@ func (f *Function[T]) Run(ctx context.Context, fn Handler[T], c ...CmdFunc[T]) e Long: "", } - if def := Default(); def != "" { - cmd, _, err := rootCmd.Find(os.Args[1:]) - if err == nil && cmd.Use == rootCmd.Use { - args := append([]string{def}, os.Args[1:]...) - rootCmd.SetArgs(args) + args := os.Args[1:] + if !namesCommand(args, cmds) { + def := Default() + if def == "" && len(cmds) == 1 { + def = cmds[0].Name() + } + if def == "" { + return errors.NotValidf("no adapter chosen among %s: name one as the first argument or set %s", + strings.Join(commandNames(cmds), ", "), Root+".default") } + rootCmd.SetArgs(append([]string{def}, args...)) } return cobra.RunContext(ctx, rootCmd, cmds...) } + +// namesCommand reports whether the first argument names one of cmds or a +// command cobra adds (help, completion). +func namesCommand(args []string, cmds []*co.Command) bool { + if len(args) == 0 { + return false + } + if args[0] == "help" || args[0] == "completion" { + return true + } + for _, cmd := range cmds { + if cmd.Name() == args[0] || cmd.HasAlias(args[0]) { + return true + } + } + return false +} + +func commandNames(cmds []*co.Command) []string { + names := make([]string, 0, len(cmds)) + for _, cmd := range cmds { + names = append(names, cmd.Name()) + } + return names +} diff --git a/bootstrap/function/function_test.go b/bootstrap/function/function_test.go new file mode 100644 index 00000000..9cf74901 --- /dev/null +++ b/bootstrap/function/function_test.go @@ -0,0 +1,92 @@ +package function_test + +import ( + "context" + "os" + "testing" + + cloudevents "github.com/cloudevents/sdk-go/v2" + co "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" + "github.com/xgodev/boost/bootstrap/function" +) + +func TestMain(m *testing.M) { + boost.Start() + os.Exit(m.Run()) +} + +func handle(context.Context, cloudevents.Event) (*cloudevents.Event, error) { return nil, nil } + +// command is an adapter command named name that records it ran. +func command(name string, ran *[]string) function.CmdFunc[*cloudevents.Event] { + return func(function.Handler[*cloudevents.Event]) *co.Command { + return &co.Command{Use: name, RunE: func(*co.Command, []string) error { + *ran = append(*ran, name) + return nil + }} + } +} + +// withArgs runs the test with the process arguments a binary gets. +func withArgs(t *testing.T, args ...string) { + t.Helper() + saved := os.Args + os.Args = append([]string{"app"}, args...) + t.Cleanup(func() { os.Args = saved }) +} + +func newFunction(t *testing.T) *function.Function[*cloudevents.Event] { + t.Helper() + f, err := function.New[*cloudevents.Event]() + require.NoError(t, err) + return f +} + +// An app wires the one adapter config chose (#203): with no argument and no +// boost.bootstrap.function.default, Run runs it. +func TestRun_TheOnlyAdapterRunsWithoutArguments(t *testing.T) { + withArgs(t) + var ran []string + + require.NoError(t, newFunction(t).Run(context.Background(), handle, command("nats", &ran))) + + assert.Equal(t, []string{"nats"}, ran) +} + +func TestRun_AnArgumentChoosesTheAdapter(t *testing.T) { + withArgs(t, "gcp_pubsub") + var ran []string + + require.NoError(t, newFunction(t).Run(context.Background(), handle, command("nats", &ran), command("gcp_pubsub", &ran))) + + assert.Equal(t, []string{"gcp_pubsub"}, ran) +} + +// A function that ends up with nothing to run fails, so the app exits +// non-zero instead of printing the usage and stopping as if it had worked. +func TestRun_NothingToRunFails(t *testing.T) { + t.Run("no adapter", func(t *testing.T) { + withArgs(t) + + err := newFunction(t).Run(context.Background(), handle) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no adapter") + }) + + t.Run("two adapters and none chosen", func(t *testing.T) { + withArgs(t) + var ran []string + + err := newFunction(t).Run(context.Background(), handle, command("nats", &ran), command("gcp_pubsub", &ran)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "nats") + assert.Contains(t, err.Error(), "gcp_pubsub") + assert.Contains(t, err.Error(), function.Root+".default") + assert.Empty(t, ran) + }) +} diff --git a/builder/README.md b/builder/README.md index af667d3a..e12c61c9 100644 --- a/builder/README.md +++ b/builder/README.md @@ -142,7 +142,9 @@ func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevent name. The door includes only the chosen adapter module, so the others and their clients are never built; an unknown name fails the boot with `NotValid` naming the key, the value and the valid names. The adapters' - own keys (`.enabled`, the pubsub `version`) stay as they are. + own keys (`.enabled`, the pubsub `version`) stay as they are. The app runs + the chosen adapter with no CLI argument and no + `boost.bootstrap.function.default`. - Each middleware is the fx module under `fx/modules/bootstrap/function/middleware` of the [`bootstrap/function/middleware`](../bootstrap/function/middleware) of that @@ -364,8 +366,8 @@ mains, _ := doors.RenderCmd(ctx) // /main.go relative to CmdDir sold-out items from its `@Config Options` (`builder.examples.simple.stock.memory.soldOut`, env `BUILDER_EXAMPLES_SIMPLE_STOCK_MEMORY_SOLD__OUT`, default `sold-out`); -- `worker`: the `ordercreated` function on the cloudevents or the nats - adapter, chosen by `worker.function.adapter` (`WORKER_FUNCTION_ADAPTER`, +- `worker`: the `ordercreated` function on the cloudevents, the nats or the + pubsub adapter, chosen by `worker.function.adapter` (`WORKER_FUNCTION_ADAPTER`, default `cloudevents`), behind the `recovery` and `logger` middlewares. `cmd/api/main.go` and `cmd/worker/main.go` are generated. Its tests call the @@ -374,8 +376,9 @@ receives the stock decorator and that prometheus counted its call, boot the api with `unlimited` (wired without the decorator), with an unknown name (the boot fails) and with `SOLD__OUT=book,pen` (the env replaces the default list), run the function handler, boot the worker with each adapter -(nats on an embedded server) and with an unknown one (the boot fails), and fail when `gen/` or a main is -stale. +(nats on an embedded server) and with an unknown one (the boot fails), run +the generated main with only `WORKER_FUNCTION_ADAPTER=nats` until it records +an order published on NATS, and fail when `gen/` or a main is stale. ## Non-goals diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index 0fa78c02..9d72d5d9 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -1,18 +1,25 @@ package main import ( + "bytes" "context" + "encoding/json" "os" "os/exec" + "strings" + "sync" "testing" + "time" "cloud.google.com/go/pubsub/pstest" cloudevents "github.com/cloudevents/sdk-go/v2" natsserver "github.com/nats-io/nats-server/v2/test" + natsgo "github.com/nats-io/nats.go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/xgodev/boost" "github.com/xgodev/boost/bootstrap/function" + natsadapter "github.com/xgodev/boost/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" "github.com/xgodev/boost/bootstrap/function/middleware/logger" "github.com/xgodev/boost/bootstrap/function/middleware/recovery" "github.com/xgodev/boost/builder/examples/simple/gen/builder/worker" @@ -120,8 +127,8 @@ func TestWorkerAdapter(t *testing.T) { } } -// mainEnv makes the process TestMain_BadConfigExitsWithTheReason starts run -// the generated main instead of the tests. +// mainEnv makes the process the TestMain_* tests start run the generated +// main instead of the tests. const mainEnv = "BUILDER_EXAMPLE_MAIN" // TestMain_BadConfigExitsWithTheReason runs the generated main of app worker @@ -139,12 +146,76 @@ func TestMain_BadConfigExitsWithTheReason(t *testing.T) { assert.Contains(t, string(out), `unknown adapter "nope"`) } -// TestMainChild runs the generated main in the process -// TestMain_BadConfigExitsWithTheReason starts. +// TestMain_RunsTheChosenAdapter runs the generated main of app worker with +// only WORKER_FUNCTION_ADAPTER=nats set (#203): no CLI argument and no +// boost.bootstrap.function.default. The worker subscribes and records the +// order this test publishes. +func TestMain_RunsTheChosenAdapter(t *testing.T) { + opts := natsserver.DefaultTestOptions + opts.Port = -1 + srv := natsserver.RunServer(&opts) + t.Cleanup(srv.Shutdown) + + conn, err := natsgo.Connect(srv.ClientURL()) + require.NoError(t, err) + t.Cleanup(conn.Close) + + var out syncBuffer + cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(os.Environ(), adapterEnv+"=nats", natsURLEnv+"="+srv.ClientURL(), mainEnv+"=1") + cmd.Stdout, cmd.Stderr = &out, &out + require.NoError(t, cmd.Start()) + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + + adapter, err := natsadapter.DefaultOptions() + require.NoError(t, err) + require.NotEmpty(t, adapter.Subjects) + payload, err := json.Marshal(event(t, map[string]string{"item": "book"})) + require.NoError(t, err) + + // the worker subscribes once it boots: publish until it records the order + tick := time.NewTicker(100 * time.Millisecond) + defer tick.Stop() + deadline := time.After(10 * time.Second) + for !strings.Contains(out.String(), "recorded order 1 of book") { + select { + case <-tick.C: + require.NoError(t, conn.Publish(adapter.Subjects[0], payload)) + case err := <-exited: + t.Fatalf("the worker exited (%v) without handling the order: %s", err, out.String()) + case <-deadline: + t.Fatalf("the worker never handled the order: %s", out.String()) + } + } +} + +// syncBuffer is a bytes.Buffer the child process and the test share. +type syncBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *syncBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *syncBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// TestMainChild runs the generated main, with no argument as a deployed +// binary gets, in the process the TestMain_* tests start. func TestMainChild(t *testing.T) { if os.Getenv(mainEnv) != "1" { - t.Skip("run by TestMain_BadConfigExitsWithTheReason") + t.Skip("run by the TestMain_* tests") } + os.Args = os.Args[:1] main() } diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index 9e475176..0711ab3c 100644 --- a/fx/modules/bootstrap/function/README.md +++ b/fx/modules/bootstrap/function/README.md @@ -14,6 +14,16 @@ fxfact.NewApp( `T` must be `*event.Event` or `[]*event.Event`. +## Which adapter runs + +`Function.Run` runs the adapter command the first CLI argument names +(`app nats`). With no command named, it runs the one +`boost.bootstrap.function.default` names, or the only adapter in the group: +an app that wires one adapter, as builder does, runs with no argument and no +key. No adapter, or more than one and none chosen, fails the boot with +`NotValid` (exit code 1) naming the adapters and the key, instead of +printing the usage. + ## Adapter clients The `nats`, `cloudevents_nats` and Pub/Sub v2 adapter modules From 41fdb346841f186043b53fbd1e244889e0aabcae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 11:04:14 -0300 Subject: [PATCH 07/11] fix(bootstrap/function): adapters stop on the run context (#204) The nats, cloudevents_nats and Pub/Sub v1 helpers subscribed with context.Background() and blocked forever, so SIGTERM left the app waiting the whole fx stop timeout and exiting 1, and a message in a handler during a deploy was cut. Their commands now run Helper.Run with cmd.Context(): when it is done they take no new message, let the handler finish the one it holds with a context the stop does not cancel (NATS drains its subscriptions), and return. A NATS subject that cannot be subscribed fails Run instead of being logged, and Pub/Sub v1 gives a failed message back instead of retrying it while stopping. Start stays, deprecated. --- .../contrib/cloud.google.com/pubsub/v1/cmd.go | 7 +- .../cloud.google.com/pubsub/v1/helper.go | 24 ++- .../cloud.google.com/pubsub/v1/run_test.go | 159 ++++++++++++++++ .../cloud.google.com/pubsub/v1/subscriber.go | 10 +- .../v2/contrib/nats-io/nats.go/v1/cmd.go | 7 +- .../v2/contrib/nats-io/nats.go/v1/helper.go | 57 ++++-- .../v2/contrib/nats-io/nats.go/v1/run_test.go | 147 +++++++++++++++ .../adapter/contrib/nats-io/nats.go/v1/cmd.go | 7 +- .../contrib/nats-io/nats.go/v1/helper.go | 55 ++++-- .../contrib/nats-io/nats.go/v1/run_test.go | 174 ++++++++++++++++++ builder/README.md | 3 +- .../examples/simple/cmd/worker/main_test.go | 30 ++- fx/modules/bootstrap/function/README.md | 9 + 13 files changed, 631 insertions(+), 58 deletions(-) create mode 100644 bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go create mode 100644 bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/run_test.go create mode 100644 bootstrap/function/adapter/contrib/nats-io/nats.go/v1/run_test.go diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/cmd.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/cmd.go index 2392db49..73a85489 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/cmd.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/cmd.go @@ -6,7 +6,8 @@ import ( "github.com/xgodev/boost/bootstrap/function" ) -// New returns CmdFunc for cloudevents command. +// New returns the CmdFunc of the gcp_pubsub command: it handles the messages +// of the configured subscriptions until the run context is done. func New[T any](client *pubsub.Client) function.CmdFunc[T] { return func(fn function.Handler[T]) *co.Command { return &co.Command{ @@ -14,9 +15,7 @@ func New[T any](client *pubsub.Client) function.CmdFunc[T] { Short: "gcp_pubsub", Long: "", RunE: func(cmd *co.Command, args []string) error { - helper := NewHelper[T](client, fn) - helper.Start() - return nil + return NewHelper[T](client, fn).Run(cmd.Context()) }, } } diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go index 0f965a6d..8a9294df 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go @@ -33,9 +33,18 @@ func NewHelper[T any](client *pubsub.Client, handler function.Handler[T]) *Helpe return NewHelperWithOptions(client, handler, opt) } -// Start subscribes to the topics and processes messages concurrently. +// Start runs the helper with no way to stop it. +// +// Deprecated: use Run with the run context, so the app stops on SIGTERM. func (h *Helper[T]) Start() { - logger := log.WithTypeOf(*h) + _ = h.Run(context.Background()) +} + +// Run receives the messages of every subscription concurrently until ctx is +// done. Then it pulls no new message, waits for the handlers to finish the +// ones they hold, and returns. +func (h *Helper[T]) Run(ctx context.Context) error { + logger := log.FromContext(ctx).WithTypeOf(*h) var wg sync.WaitGroup // Subscribe to each subscription in a goroutine @@ -45,17 +54,14 @@ func (h *Helper[T]) Start() { go func(subscription string) { defer wg.Done() - subscriber := NewSubscriber[T](h.client, h.handler, subscription, h.options) - - // Subscribe to the subscription - if err := subscriber.Subscribe(context.Background()); err != nil { + logger.Infof("pubsub: receiving on %s", subscription) + if err := NewSubscriber[T](h.client, h.handler, subscription, h.options).Subscribe(ctx); err != nil { logger.Errorf("Failed to subscribe to subscription %s: %v", subscription, err) - } else { - logger.Infof("Successfully subscribed to subscription %s", subscription) } }(subscription) } - // Wait for all subscriptions to complete + // Wait for all subscriptions to stop wg.Wait() + return nil } diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go new file mode 100644 index 00000000..9a7f3413 --- /dev/null +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go @@ -0,0 +1,159 @@ +package pubsub + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "cloud.google.com/go/pubsub" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" +) + +// blocking is a handler that signals when it takes a message, holds it +// until release is closed, and records whether its context was cancelled. +type blocking struct { + started chan struct{} + release chan struct{} + handled atomic.Int32 + cancelled atomic.Bool + fail bool +} + +func newBlocking() *blocking { + return &blocking{started: make(chan struct{}, 10), release: make(chan struct{})} +} + +func (b *blocking) handle(ctx context.Context, _ cloudevents.Event) (cloudevents.Event, error) { + b.started <- struct{}{} + <-b.release + b.cancelled.Store(ctx.Err() != nil) + b.handled.Add(1) + if b.fail { + return cloudevents.Event{}, errors.New("handler failed") + } + return cloudevents.Event{}, nil +} + +// subscription creates topic and subscription name on the pstest server of +// client. +func subscription(t *testing.T, ctx context.Context, client *pubsub.Client, name string) *pubsub.Topic { + t.Helper() + topic, err := client.CreateTopic(ctx, name) + require.NoError(t, err) + _, err = client.CreateSubscription(ctx, name, pubsub.SubscriptionConfig{Topic: topic, AckDeadline: 10 * time.Second}) + require.NoError(t, err) + return topic +} + +func publish(t *testing.T, ctx context.Context, topic *pubsub.Topic, data string) { + t.Helper() + _, err := topic.Publish(ctx, &pubsub.Message{Data: []byte(data)}).Get(ctx) + require.NoError(t, err) +} + +func options(subscription string) *Options { + return &Options{Subscriptions: []string{subscription}, Concurrency: 1, RetryLimit: -1, BackoffBase: time.Millisecond, MaxBackoff: time.Millisecond, ProcessTimeout: 10 * time.Second} +} + +// TestHelperRun_StopsOnTheRunContext cancels the run context while the +// handler holds a message (#204): the handler finishes with a context the +// stop did not cancel, Run returns, and no new message is taken. +func TestHelperRun_StopsOnTheRunContext(t *testing.T) { + boost.Start() + client, ctx := setup(t) + topic := subscription(t, ctx, client, "orders") + h := newBlocking() + helper := NewHelperWithOptions[cloudevents.Event](client, h.handle, options("orders")) + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(runCtx) }() + + publish(t, ctx, topic, `{"item":"book"}`) + select { + case <-h.started: + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the message") + } + + cancel() + select { + case err := <-ran: + t.Fatalf("Run returned (%v) while the handler held a message", err) + case <-time.After(200 * time.Millisecond): + } + + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after the handler finished") + } + require.EqualValues(t, 1, h.handled.Load()) + require.False(t, h.cancelled.Load(), "the stop cancelled the context of the handler") + + publish(t, ctx, topic, `{"item":"pen"}`) + time.Sleep(300 * time.Millisecond) + require.EqualValues(t, 1, h.handled.Load(), "a message published after Run returned was taken") +} + +// TestHelperRun_StopsRetryingOnTheRunContext: a handler that fails while the +// app stops is not retried; the message goes back to Pub/Sub. +func TestHelperRun_StopsRetryingOnTheRunContext(t *testing.T) { + boost.Start() + client, ctx := setup(t) + topic := subscription(t, ctx, client, "orders") + h := newBlocking() + h.fail = true + helper := NewHelperWithOptions[cloudevents.Event](client, h.handle, options("orders")) + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(runCtx) }() + + publish(t, ctx, topic, `{"item":"book"}`) + select { + case <-h.started: + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the message") + } + cancel() + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Run kept retrying after the stop") + } + require.EqualValues(t, 1, h.handled.Load()) +} + +// TestNew_RunsUntilTheCommandContextIsDone: the gcp_pubsub command runs the +// helper, with the subscriptions of config, until the context cobra +// executes it with is done. +func TestNew_RunsUntilTheCommandContextIsDone(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "changeme") + cmd := New[cloudevents.Event](client)(newBlocking().handle) + require.Equal(t, "gcp_pubsub", cmd.Name()) + + runCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) + defer cancel() + cmd.SetArgs([]string{}) + done := make(chan error, 1) + go func() { done <- cmd.ExecuteContext(runCtx) }() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("the command did not stop with its context") + } +} diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go index 43437de3..b3b37b31 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go @@ -72,8 +72,9 @@ func (l *Subscriber[T]) processMessage(ctx context.Context, msg *pubsub.Message) } for { - // Timeout por tentativa - msgCtx, cancel := context.WithTimeout(ctx, l.options.ProcessTimeout) + // Timeout por tentativa. The stop of the app cancels ctx: the handler + // finishes the message it holds instead of failing on it. + msgCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), l.options.ProcessTimeout) // Processes the event via handler if _, err := l.handler(msgCtx, in); err != nil { @@ -87,6 +88,11 @@ func (l *Subscriber[T]) processMessage(ctx context.Context, msg *pubsub.Message) return errors.Wrap(err, errors.Internalf("max retry limit reached")) } + // The app is stopping: give the message back instead of retrying + if ctx.Err() != nil { + return errors.Wrap(err, errors.Internalf("stopped before a retry")) + } + // Apply backoff if enabled if l.options.Backoff { l.applyBackoff(retryCount) diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/cmd.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/cmd.go index 5e043458..fb83328a 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/cmd.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/cmd.go @@ -6,7 +6,8 @@ import ( "github.com/xgodev/boost/bootstrap/function" ) -// New returns CmdFunc for cloudevents command. +// New returns the CmdFunc of the cloudevents_nats command: it handles the +// events of the configured subjects until the run context is done. func New[T any](conn *nats.Conn) function.CmdFunc[T] { return func(fn function.Handler[T]) *co.Command { return &co.Command{ @@ -14,9 +15,7 @@ func New[T any](conn *nats.Conn) function.CmdFunc[T] { Short: "cloudevents_nats", Long: "", RunE: func(cmd *co.Command, args []string) error { - helper := NewHelper[T](conn, fn) - helper.Start() - return nil + return NewHelper[T](conn, fn).Run(cmd.Context()) }, } } diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/helper.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/helper.go index ce61c078..32ffbfb2 100644 --- a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/helper.go +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/helper.go @@ -2,10 +2,13 @@ package nats import ( "context" + "sync" + cenats "github.com/cloudevents/sdk-go/protocol/nats/v2" cloudevents "github.com/cloudevents/sdk-go/v2" "github.com/nats-io/nats.go" "github.com/xgodev/boost/bootstrap/function" + "github.com/xgodev/boost/model/errors" "github.com/xgodev/boost/wrapper/log" ) @@ -40,42 +43,66 @@ func NewHelper[T any](conn *nats.Conn, handler function.Handler[T]) *Helper[T] { return NewHelperWithOptions(conn, handler, opt) } +// Start runs the helper with no way to stop it. +// +// Deprecated: use Run with the run context, so the app stops on SIGTERM. func (h *Helper[T]) Start() { - - for i := range h.subjects { - go h.subscribe(context.Background(), h.subjects[i]) + if err := h.Run(context.Background()); err != nil { + log.WithTypeOf(*h).Error(err) } - - c := make(chan struct{}) - <-c } -func (h *Helper[T]) subscribe(ctx context.Context, subject string) { +// Run receives the events of every subject until ctx is done. Then each +// receiver drains its subscription, waits for the handlers to finish the +// events they hold, and Run returns. A subject it cannot receive on stops +// the others and fails Run. +func (h *Helper[T]) Run(ctx context.Context) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errs := make(chan error, len(h.subjects)) + var wg sync.WaitGroup + for _, subject := range h.subjects { + wg.Add(1) + go func(subject string) { + defer wg.Done() + if err := h.subscribe(ctx, subject); err != nil { + errs <- err + cancel() + } + }(subject) + } + wg.Wait() + close(errs) + return <-errs +} - logger := log.FromContext(ctx) +func (h *Helper[T]) subscribe(ctx context.Context, subject string) error { p, err := cenats.NewConsumerFromConn(h.conn, subject, cenats.WithQueueSubscriber(h.queue)) if err != nil { - logger.Fatalf("failed to create nats protocol, %s", err.Error()) + return errors.Wrap(err, errors.Internalf("cloudevents nats: could not create the consumer of %s", subject)) } defer p.Close(ctx) c, err := cloudevents.NewClient(p) if err != nil { - logger.Fatalf("failed to create client, %s", err.Error()) - return + return errors.Wrap(err, errors.Internalf("cloudevents nats: could not create the client of %s", subject)) } // the nats consumer has no responder, so the client refuses a receiver // that returns an event: what the function returns is for its - // middlewares, like publisher, not for the protocol + // middlewares, like publisher, not for the protocol. The stop of the app + // cancels ctx: the handler finishes the event it holds instead of + // failing on it. receive := func(ctx context.Context, in cloudevents.Event) error { - _, err := h.handler(ctx, in) + _, err := h.handler(context.WithoutCancel(ctx), in) return err } + log.FromContext(ctx).WithTypeOf(*h).Infof("cloudevents nats: receiving on %s with queue %s", subject, h.queue) if err := c.StartReceiver(ctx, receive); err != nil { - logger.Errorf("failed to start nats receiver, %s", err.Error()) + return errors.Wrap(err, errors.Internalf("cloudevents nats: could not receive on %s with queue %s", subject, h.queue)) } - + return nil } diff --git a/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/run_test.go b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/run_test.go new file mode 100644 index 00000000..cd6b4df3 --- /dev/null +++ b/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/run_test.go @@ -0,0 +1,147 @@ +package nats + +import ( + "context" + "sync/atomic" + "testing" + "time" + + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/nats-io/nats-server/v2/server" + natsserver "github.com/nats-io/nats-server/v2/test" + "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" +) + +// blocking is a handler that signals when it takes an event and holds it +// until release is closed, and records whether its context was cancelled. +type blocking struct { + started chan struct{} + release chan struct{} + handled atomic.Int32 + cancelled atomic.Bool +} + +func newBlocking() *blocking { + return &blocking{started: make(chan struct{}, 10), release: make(chan struct{})} +} + +func (b *blocking) handle(ctx context.Context, _ cloudevents.Event) (*cloudevents.Event, error) { + b.started <- struct{}{} + <-b.release + b.cancelled.Store(ctx.Err() != nil) + b.handled.Add(1) + return nil, nil +} + +func serve(t *testing.T) (*server.Server, *nats.Conn) { + t.Helper() + boost.Start() + opts := natsserver.DefaultTestOptions + opts.Port = -1 + srv := natsserver.RunServer(&opts) + t.Cleanup(srv.Shutdown) + conn, err := nats.Connect(srv.ClientURL()) + require.NoError(t, err) + t.Cleanup(conn.Close) + return srv, conn +} + +func event(id string) []byte { + return []byte(`{"specversion":"1.0","id":"` + id + `","source":"test","type":"order.created"}`) +} + +// TestHelperRun_StopsOnTheRunContext cancels the run context while the +// handler holds an event (#204): the handler finishes with a context the +// stop did not cancel, Run returns, and no new event is taken. +func TestHelperRun_StopsOnTheRunContext(t *testing.T) { + srv, conn := serve(t) + before := srv.NumSubscriptions() + h := newBlocking() + helper := NewHelperWithOptions[*cloudevents.Event](conn, h.handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(ctx) }() + + require.Eventually(t, func() bool { return srv.NumSubscriptions() > before }, 5*time.Second, 10*time.Millisecond) + require.NoError(t, conn.Publish("orders", event("1"))) + select { + case <-h.started: + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the event") + } + + cancel() + select { + case err := <-ran: + t.Fatalf("Run returned (%v) while the handler held an event", err) + case <-time.After(200 * time.Millisecond): + } + + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after the handler finished") + } + require.EqualValues(t, 1, h.handled.Load()) + require.False(t, h.cancelled.Load(), "the stop cancelled the context of the handler") + + require.NoError(t, conn.Publish("orders", event("2"))) + require.NoError(t, conn.Flush()) + time.Sleep(200 * time.Millisecond) + require.EqualValues(t, 1, h.handled.Load(), "an event published after Run returned was taken") +} + +// TestHelperRun_FailsWhenItCannotSubscribe returns the error of a subject it +// cannot receive on, and stops the others, instead of exiting the process or +// idling (#204). +func TestHelperRun_FailsWhenItCannotSubscribe(t *testing.T) { + srv, conn := serve(t) + before := srv.NumSubscriptions() + helper := NewHelperWithOptions[*cloudevents.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders", ""}, Queue: "workers"}) + + ran := make(chan error, 1) + go func() { ran <- helper.Run(context.Background()) }() + select { + case err := <-ran: + require.ErrorIs(t, err, nats.ErrBadSubject) + case <-time.After(2 * time.Second): + t.Fatal("Run idled on a subject it could not receive on") + } + require.NoError(t, conn.Flush()) + require.Eventually(t, func() bool { return srv.NumSubscriptions() == before }, 2*time.Second, 10*time.Millisecond) +} + +// TestHelperStart_ReturnsWhenItCannotSubscribe keeps Start for callers +// without a context: it logs the error and returns. +func TestHelperStart_ReturnsWhenItCannotSubscribe(t *testing.T) { + _, conn := serve(t) + conn.Close() + helper := NewHelperWithOptions[*cloudevents.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + done := make(chan struct{}) + go func() { helper.Start(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Start idled on a subject it could not receive on") + } +} + +// TestNew_RunsUntilTheCommandContextIsDone: the cloudevents_nats command +// runs the helper with the context cobra executes it with. +func TestNew_RunsUntilTheCommandContextIsDone(t *testing.T) { + _, conn := serve(t) + cmd := New[*cloudevents.Event](conn)(newBlocking().handle) + require.Equal(t, "cloudevents_nats", cmd.Name()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd.SetArgs([]string{}) + require.NoError(t, cmd.ExecuteContext(ctx)) +} diff --git a/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/cmd.go b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/cmd.go index 1daec312..c6f50e46 100644 --- a/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/cmd.go +++ b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/cmd.go @@ -6,7 +6,8 @@ import ( "github.com/xgodev/boost/bootstrap/function" ) -// New returns CmdFunc for cloudevents command. +// New returns the CmdFunc of the nats command: it handles the messages of +// the configured subjects until the run context is done. func New[T any](conn *nats.Conn) function.CmdFunc[T] { return func(fn function.Handler[T]) *co.Command { return &co.Command{ @@ -14,9 +15,7 @@ func New[T any](conn *nats.Conn) function.CmdFunc[T] { Short: "nats", Long: "", RunE: func(cmd *co.Command, args []string) error { - helper := NewHelper[T](conn, fn) - helper.Start() - return nil + return NewHelper[T](conn, fn).Run(cmd.Context()) }, } } diff --git a/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/helper.go b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/helper.go index b03c4cb8..47a06ba3 100644 --- a/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/helper.go +++ b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/helper.go @@ -3,6 +3,7 @@ package nats import ( "context" "github.com/xgodev/boost/bootstrap/function" + "github.com/xgodev/boost/model/errors" "github.com/xgodev/boost/wrapper/log" "github.com/nats-io/nats.go" @@ -38,30 +39,50 @@ func NewHelper[T any](conn *nats.Conn, handler function.Handler[T]) *Helper[T] { return NewHelperWithOptions(conn, handler, opt) } +// Start runs the helper with no way to stop it. +// +// Deprecated: use Run with the run context, so the app stops on SIGTERM. func (h *Helper[T]) Start() { - - for i := range h.subjects { - go h.subscribe(context.Background(), h.subjects[i]) + if err := h.Run(context.Background()); err != nil { + log.WithTypeOf(*h).Error(err) } - - c := make(chan struct{}) - <-c } -func (h *Helper[T]) subscribe(ctx context.Context, subject string) { - +// Run subscribes to every subject and handles their messages until ctx is +// done. Then it drains the subscriptions: it takes no new message, lets the +// handlers finish the ones they hold, and returns. A subject it cannot +// subscribe to fails Run, after unsubscribing the ones it already has. +func (h *Helper[T]) Run(ctx context.Context) error { logger := log.FromContext(ctx).WithTypeOf(*h) - subscriber := NewSubscriber[T](h.conn, h.handler, subject, h.queue) - subscribe, err := subscriber.Subscribe(ctx) - if err != nil { - logger.Error(err) - } - - if subscribe.IsValid() { + var subs []*nats.Subscription + for _, subject := range h.subjects { + sub, err := NewSubscriber[T](h.conn, h.handler, subject, h.queue).Subscribe(ctx) + if err != nil { + for _, s := range subs { + _ = s.Unsubscribe() + } + return errors.Wrap(err, errors.Internalf("nats: could not subscribe on %s with queue %s", subject, h.queue)) + } logger.Infof("nats: subscribed on %s with queue %s", subject, h.queue) - } else { - logger.Errorf("nats: not subscribed on %s with queue %s", subject, h.queue) + subs = append(subs, sub) } + <-ctx.Done() + + // drain them all before waiting, so no subject takes new messages while + // another one finishes + var closed []<-chan nats.SubStatus + for _, sub := range subs { + ch := sub.StatusChanged(nats.SubscriptionClosed) + if err := sub.Drain(); err != nil { + logger.Warnf("nats: could not drain %s: %v", sub.Subject, err) + continue + } + closed = append(closed, ch) + } + for _, ch := range closed { + <-ch + } + return nil } diff --git a/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/run_test.go b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/run_test.go new file mode 100644 index 00000000..317fd009 --- /dev/null +++ b/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/run_test.go @@ -0,0 +1,174 @@ +package nats + +import ( + "context" + "sync/atomic" + "testing" + "time" + + v2 "github.com/cloudevents/sdk-go/v2" + "github.com/nats-io/nats-server/v2/server" + natsserver "github.com/nats-io/nats-server/v2/test" + n "github.com/nats-io/nats.go" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" +) + +// blocking is a handler that signals when it takes a message and holds it +// until release is closed, and records whether its context was cancelled. +type blocking struct { + started chan struct{} + release chan struct{} + handled atomic.Int32 + cancelled atomic.Bool +} + +func newBlocking() *blocking { + return &blocking{started: make(chan struct{}, 10), release: make(chan struct{})} +} + +func (b *blocking) handle(ctx context.Context, _ v2.Event) (*v2.Event, error) { + b.started <- struct{}{} + <-b.release + b.cancelled.Store(ctx.Err() != nil) + b.handled.Add(1) + return nil, nil +} + +func serve(t *testing.T) (*server.Server, *n.Conn) { + t.Helper() + boost.Start() + opts := natsserver.DefaultTestOptions + opts.Port = -1 + srv := natsserver.RunServer(&opts) + t.Cleanup(srv.Shutdown) + conn, err := n.Connect(srv.ClientURL()) + require.NoError(t, err) + t.Cleanup(conn.Close) + return srv, conn +} + +// TestHelperRun_StopsOnTheRunContext cancels the run context while the +// handler holds a message (#204): the handler finishes with a context the +// stop did not cancel, Run returns, and no new message is taken. +func TestHelperRun_StopsOnTheRunContext(t *testing.T) { + srv, conn := serve(t) + before := srv.NumSubscriptions() + h := newBlocking() + helper := NewHelperWithOptions[*v2.Event](conn, h.handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(ctx) }() + + require.Eventually(t, func() bool { return srv.NumSubscriptions() > before }, 5*time.Second, 10*time.Millisecond) + require.NoError(t, conn.Publish("orders", []byte(`{"item":"book"}`))) + select { + case <-h.started: + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the message") + } + + cancel() + select { + case err := <-ran: + t.Fatalf("Run returned (%v) while the handler held a message", err) + case <-time.After(200 * time.Millisecond): + } + + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Run did not return after the handler finished") + } + require.EqualValues(t, 1, h.handled.Load()) + require.False(t, h.cancelled.Load(), "the stop cancelled the context of the handler") + + require.NoError(t, conn.Publish("orders", []byte(`{"item":"pen"}`))) + require.NoError(t, conn.Flush()) + time.Sleep(200 * time.Millisecond) + require.EqualValues(t, 1, h.handled.Load(), "a message published after Run returned was taken") +} + +// TestHelperRun_FailsWhenItCannotSubscribe returns the subscribe error +// instead of logging it and idling (#204). +func TestHelperRun_FailsWhenItCannotSubscribe(t *testing.T) { + _, conn := serve(t) + conn.Close() + helper := NewHelperWithOptions[*v2.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + ran := make(chan error, 1) + go func() { ran <- helper.Run(context.Background()) }() + select { + case err := <-ran: + require.ErrorIs(t, err, n.ErrConnectionClosed) + case <-time.After(2 * time.Second): + t.Fatal("Run idled on a subscription that failed") + } +} + +// TestHelperRun_UnsubscribesWhatItSubscribed: a subject that fails after +// another one subscribed leaves no subscription behind. +func TestHelperRun_UnsubscribesWhatItSubscribed(t *testing.T) { + srv, conn := serve(t) + before := srv.NumSubscriptions() + helper := NewHelperWithOptions[*v2.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders", ""}, Queue: "workers"}) + + require.ErrorIs(t, helper.Run(context.Background()), n.ErrBadSubject) + require.NoError(t, conn.Flush()) + require.Eventually(t, func() bool { return srv.NumSubscriptions() == before }, 2*time.Second, 10*time.Millisecond) +} + +// TestHelperStart_ReturnsWhenItCannotSubscribe keeps Start for callers +// without a context: it logs the error and returns. +func TestHelperStart_ReturnsWhenItCannotSubscribe(t *testing.T) { + _, conn := serve(t) + conn.Close() + helper := NewHelperWithOptions[*v2.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + done := make(chan struct{}) + go func() { helper.Start(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Start idled on a subscription that failed") + } +} + +// TestHelperRun_ReturnsWhenTheConnectionClosed: a connection closed under Run +// leaves nothing to drain, and Run still returns once ctx is done. +func TestHelperRun_ReturnsWhenTheConnectionClosed(t *testing.T) { + srv, conn := serve(t) + before := srv.NumSubscriptions() + helper := NewHelperWithOptions[*v2.Event](conn, newBlocking().handle, &Options{Subjects: []string{"orders"}, Queue: "workers"}) + + ctx, cancel := context.WithCancel(context.Background()) + ran := make(chan error, 1) + go func() { ran <- helper.Run(ctx) }() + require.Eventually(t, func() bool { return srv.NumSubscriptions() > before }, 5*time.Second, 10*time.Millisecond) + + conn.Close() + cancel() + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("Run did not return") + } +} + +// TestNew_RunsUntilTheCommandContextIsDone: the nats command runs the helper +// with the context cobra executes it with. +func TestNew_RunsUntilTheCommandContextIsDone(t *testing.T) { + _, conn := serve(t) + cmd := New[*v2.Event](conn)(newBlocking().handle) + require.Equal(t, "nats", cmd.Name()) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + cmd.SetArgs([]string{}) + require.NoError(t, cmd.ExecuteContext(ctx)) +} diff --git a/builder/README.md b/builder/README.md index e12c61c9..af796b5b 100644 --- a/builder/README.md +++ b/builder/README.md @@ -378,7 +378,8 @@ api with `unlimited` (wired without the decorator), with an unknown name default list), run the function handler, boot the worker with each adapter (nats on an embedded server) and with an unknown one (the boot fails), run the generated main with only `WORKER_FUNCTION_ADAPTER=nats` until it records -an order published on NATS, and fail when `gen/` or a main is stale. +an order published on NATS and then send it SIGTERM (it exits 0 within a +second), and fail when `gen/` or a main is stale. ## Non-goals diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index 9d72d5d9..ed37ce80 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -8,6 +8,7 @@ import ( "os/exec" "strings" "sync" + "syscall" "testing" "time" @@ -151,6 +152,30 @@ func TestMain_BadConfigExitsWithTheReason(t *testing.T) { // boost.bootstrap.function.default. The worker subscribes and records the // order this test publishes. func TestMain_RunsTheChosenAdapter(t *testing.T) { + recordOrder(t) +} + +// TestMain_StopsOnSIGTERM sends SIGTERM to the generated main of app worker +// after it recorded an order (#204): the nats adapter drains and returns, so +// the worker exits 0 within a second instead of waiting the fx stop timeout. +func TestMain_StopsOnSIGTERM(t *testing.T) { + cmd, out, exited := recordOrder(t) + + require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) + select { + case err := <-exited: + require.NoError(t, err, "the worker did not exit 0: %s", out.String()) + case <-time.After(time.Second): + t.Fatalf("the worker did not stop within a second of SIGTERM: %s", out.String()) + } +} + +// recordOrder runs the generated main of app worker with only +// WORKER_FUNCTION_ADAPTER=nats on an embedded server, publishes an order until +// the worker records it, and returns the running worker, its output and the +// channel its exit goes to. +func recordOrder(t *testing.T) (*exec.Cmd, *syncBuffer, <-chan error) { + t.Helper() opts := natsserver.DefaultTestOptions opts.Port = -1 srv := natsserver.RunServer(&opts) @@ -160,10 +185,10 @@ func TestMain_RunsTheChosenAdapter(t *testing.T) { require.NoError(t, err) t.Cleanup(conn.Close) - var out syncBuffer + out := &syncBuffer{} cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") cmd.Env = append(os.Environ(), adapterEnv+"=nats", natsURLEnv+"="+srv.ClientURL(), mainEnv+"=1") - cmd.Stdout, cmd.Stderr = &out, &out + cmd.Stdout, cmd.Stderr = out, out require.NoError(t, cmd.Start()) exited := make(chan error, 1) go func() { exited <- cmd.Wait() }() @@ -189,6 +214,7 @@ func TestMain_RunsTheChosenAdapter(t *testing.T) { t.Fatalf("the worker never handled the order: %s", out.String()) } } + return cmd, out, exited } // syncBuffer is a bytes.Buffer the child process and the test share. diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index 0711ab3c..6b1b7bb8 100644 --- a/fx/modules/bootstrap/function/README.md +++ b/fx/modules/bootstrap/function/README.md @@ -63,3 +63,12 @@ fx.Provide(fx.Annotate( (fx's stop timeout), and returns the context error if it does not. - **Run ends on its own** (no stop requested) — the app is shut down with exit code 0 on success, or 1 when `Run` returned an error (logged). + +The `nats`, `cloudevents_nats` and `gcp_pubsub` (v1) adapters run on that +context (`Helper.Run(cmd.Context())`): when it is cancelled they take no new +message, let the handler finish the one it holds, with a context the stop +does not cancel, and return, so SIGTERM stops the app with exit code 0 well +before the stop timeout. NATS drains its subscriptions; Pub/Sub v1 gives back +a failed message instead of retrying it. A NATS subject it cannot subscribe +to fails `Run`. `Helper.Start()` is deprecated: it runs with a context that +is never cancelled. From 02d417768b58ac1d3b097a44c69aded90d65981c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 11:15:22 -0300 Subject: [PATCH 08/11] fix(bootstrap/function): Pub/Sub adapters run in a builder app (#205) No Pub/Sub path worked from a builder app. On v2 the command started one goroutine per subscription and returned at once, so the function module took it as finished and shut the app down with exit code 0; a subscription that could not be received was only logged. On v1 the app did not boot: the adapter module asked for the v1 client through the factory module, which provides it only when boost.factory.gcp.pubsub.version is v1, and that key defaults to v2. Both helpers now Run on the command context like the NATS adapters: they block until it is done, let the handler finish the message it holds with a context the stop does not cancel, stop retrying, and return. A subscription that cannot be received stops the others and fails Run with an error naming it, so the boot fails instead of running without it. The v1 adapter module provides the v1 client through ClientModule, whatever the factory version. --- .../cloud.google.com/pubsub/v1/helper.go | 24 ++- .../cloud.google.com/pubsub/v1/run_test.go | 21 ++ .../cloud.google.com/pubsub/v1/subscriber.go | 6 +- .../contrib/cloud.google.com/pubsub/v2/cmd.go | 4 +- .../cloud.google.com/pubsub/v2/helper.go | 41 +++- .../cloud.google.com/pubsub/v2/run_test.go | 188 ++++++++++++++++++ .../cloud.google.com/pubsub/v2/subscriber.go | 19 +- builder/README.md | 8 +- .../examples/simple/cmd/worker/main_test.go | 126 ++++++++++++ fx/modules/bootstrap/function/README.md | 23 ++- .../cloud.google.com/pubsub/v1/config_test.go | 5 +- .../cloud.google.com/pubsub/v1/module.go | 2 +- .../cloud.google.com/pubsub/v1/module.go | 15 +- 13 files changed, 445 insertions(+), 37 deletions(-) create mode 100644 bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/run_test.go diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go index 8a9294df..e2f09103 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/helper.go @@ -33,18 +33,28 @@ func NewHelper[T any](client *pubsub.Client, handler function.Handler[T]) *Helpe return NewHelperWithOptions(client, handler, opt) } -// Start runs the helper with no way to stop it. +// Start runs the helper with no way to stop it, and logs the error Run +// returns. // -// Deprecated: use Run with the run context, so the app stops on SIGTERM. +// Deprecated: use Run with the run context, so the app stops on SIGTERM and +// fails when a subscription cannot be received. func (h *Helper[T]) Start() { - _ = h.Run(context.Background()) + if err := h.Run(context.Background()); err != nil { + log.WithTypeOf(*h).Error(err.Error()) + } } // Run receives the messages of every subscription concurrently until ctx is // done. Then it pulls no new message, waits for the handlers to finish the -// ones they hold, and returns. +// ones they hold, and returns. When a subscription cannot be received, for +// instance because it does not exist, Run stops the others and returns that +// error, so the app fails instead of running without it. func (h *Helper[T]) Run(ctx context.Context) error { logger := log.FromContext(ctx).WithTypeOf(*h) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errs := make(chan error, len(h.options.Subscriptions)) var wg sync.WaitGroup // Subscribe to each subscription in a goroutine @@ -56,12 +66,14 @@ func (h *Helper[T]) Run(ctx context.Context) error { logger.Infof("pubsub: receiving on %s", subscription) if err := NewSubscriber[T](h.client, h.handler, subscription, h.options).Subscribe(ctx); err != nil { - logger.Errorf("Failed to subscribe to subscription %s: %v", subscription, err) + errs <- err + cancel() } }(subscription) } // Wait for all subscriptions to stop wg.Wait() - return nil + close(errs) + return <-errs } diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go index 9a7f3413..d23c8c46 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go @@ -135,6 +135,27 @@ func TestHelperRun_StopsRetryingOnTheRunContext(t *testing.T) { require.EqualValues(t, 1, h.handled.Load()) } +// TestHelperRun_FailsWhenASubscriptionIsMissing: one of the subscriptions +// does not exist (#205). Run stops the others and returns an error naming +// it, so the app fails its boot instead of running without it. +func TestHelperRun_FailsWhenASubscriptionIsMissing(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "orders") + opts := options("orders") + opts.Subscriptions = append(opts.Subscriptions, "missing") + helper := NewHelperWithOptions[cloudevents.Event](client, newBlocking().handle, opts) + + ran := make(chan error, 1) + go func() { ran <- helper.Run(ctx) }() + select { + case err := <-ran: + require.ErrorContains(t, err, "pubsub: could not receive on missing") + case <-time.After(5 * time.Second): + t.Fatal("Run kept running without subscription missing") + } +} + // TestNew_RunsUntilTheCommandContextIsDone: the gcp_pubsub command runs the // helper, with the subscriptions of config, until the context cobra // executes it with is done. diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go index b3b37b31..3df3b5c7 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/subscriber.go @@ -32,7 +32,9 @@ func NewSubscriber[T any](client *pubsub.Client, handler function.Handler[T], su } } -// Subscribe subscribes and consumes messages from multiple Pub/Sub topics concurrently +// Subscribe receives the messages of the subscription until ctx is done, and +// returns once the handlers finished the ones they hold. It returns an error +// naming the subscription when it cannot be received. func (l *Subscriber[T]) Subscribe(ctx context.Context) error { logger := log.FromContext(ctx).WithTypeOf(*l) @@ -53,7 +55,7 @@ func (l *Subscriber[T]) Subscribe(ctx context.Context) error { }) if err != nil { - logger.Fatalf("Failed to start subscription %s: %v", l.subscription, err) + return errors.Wrap(err, errors.Internalf("pubsub: could not receive on %s", l.subscription)) } return nil diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/cmd.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/cmd.go index 0cfabf01..a6d97849 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/cmd.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/cmd.go @@ -14,9 +14,7 @@ func New[T any](client *pubsub.Client) function.CmdFunc[T] { Short: "gcp_pubsub", Long: "", RunE: func(cmd *co.Command, args []string) error { - helper := NewHelper[T](client, fn) - helper.Start() - return nil + return NewHelper[T](client, fn).Run(cmd.Context()) }, } } diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/helper.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/helper.go index 4068a35b..849eb3d8 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/helper.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/helper.go @@ -2,6 +2,7 @@ package pubsub import ( "context" + "sync" "cloud.google.com/go/pubsub/v2" "github.com/xgodev/boost/bootstrap/function" @@ -33,17 +34,47 @@ func NewHelper[T any](client *pubsub.Client, handler function.Handler[T]) *Helpe return NewHelperWithOptions(client, handler, opt) } -// Start subscribes to the topics and processes messages concurrently. +// Start runs the helper with no way to stop it, and logs the error Run +// returns. +// +// Deprecated: use Run with the run context, so the app stops on SIGTERM and +// fails when a subscription cannot be received. func (h *Helper[T]) Start() { + if err := h.Run(context.Background()); err != nil { + log.WithTypeOf(*h).Error(err.Error()) + } +} + +// Run receives the messages of every subscription concurrently until ctx is +// done. Then it pulls no new message, waits for the handlers to finish the +// ones they hold, and returns. When a subscription cannot be received, for +// instance because it does not exist, Run stops the others and returns that +// error, so the app fails instead of running without it. +func (h *Helper[T]) Run(ctx context.Context) error { + logger := log.FromContext(ctx).WithTypeOf(*h) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + errs := make(chan error, len(h.options.Subscriptions)) + var wg sync.WaitGroup // Subscribe to each subscription in a goroutine for _, subscription := range h.options.Subscriptions { + wg.Add(1) + go func(subscription string) { - // Subscribe to the subscription - if err := NewSubscriber[T](h.client, h.handler, subscription, h.options).Subscribe(context.Background()); err != nil { - logger := log.WithTypeOf(*h) - logger.Errorf("Failed to subscribe to subscription %s: %v", subscription, err) + defer wg.Done() + + logger.Infof("pubsub: receiving on %s", subscription) + if err := NewSubscriber[T](h.client, h.handler, subscription, h.options).Subscribe(ctx); err != nil { + errs <- err + cancel() } }(subscription) } + + // Wait for all subscriptions to stop + wg.Wait() + close(errs) + return <-errs } diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/run_test.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/run_test.go new file mode 100644 index 00000000..3fc87576 --- /dev/null +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/run_test.go @@ -0,0 +1,188 @@ +package pubsub + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" + + "cloud.google.com/go/pubsub/v2" + "cloud.google.com/go/pubsub/v2/apiv1/pubsubpb" + cloudevents "github.com/cloudevents/sdk-go/v2" + "github.com/stretchr/testify/require" + "github.com/xgodev/boost" +) + +// blocking is a handler that signals when it takes a message, holds it +// until release is closed, and records whether its context was cancelled. +type blocking struct { + started chan struct{} + release chan struct{} + handled atomic.Int32 + cancelled atomic.Bool + fail bool +} + +func newBlocking() *blocking { + return &blocking{started: make(chan struct{}, 10), release: make(chan struct{})} +} + +func (b *blocking) handle(ctx context.Context, _ cloudevents.Event) (cloudevents.Event, error) { + b.started <- struct{}{} + <-b.release + b.cancelled.Store(ctx.Err() != nil) + b.handled.Add(1) + if b.fail { + return cloudevents.Event{}, errors.New("handler failed") + } + return cloudevents.Event{}, nil +} + +// subscription creates topic and subscription name on the pstest server of +// client. +func subscription(t *testing.T, ctx context.Context, client *pubsub.Client, name string) { + t.Helper() + topic := "projects/local-client/topics/" + name + _, err := client.TopicAdminClient.CreateTopic(ctx, &pubsubpb.Topic{Name: topic}) + require.NoError(t, err) + _, err = client.SubscriptionAdminClient.CreateSubscription(ctx, &pubsubpb.Subscription{ + Name: "projects/local-client/subscriptions/" + name, Topic: topic, AckDeadlineSeconds: 10, + }) + require.NoError(t, err) +} + +func publish(t *testing.T, ctx context.Context, client *pubsub.Client, topic, data string) { + t.Helper() + pub := client.Publisher(topic) + defer pub.Stop() + _, err := pub.Publish(ctx, &pubsub.Message{Data: []byte(data)}).Get(ctx) + require.NoError(t, err) +} + +func options(subscriptions ...string) *Options { + return &Options{Subscriptions: subscriptions, Concurrency: 1, RetryLimit: 100, BackoffBase: time.Millisecond, MaxBackoff: time.Millisecond, ProcessTimeout: 10 * time.Second} +} + +// TestHelperRun_StopsOnTheRunContext cancels the run context while the +// handler holds a message (#205): Run blocks until then, the handler +// finishes with a context the stop did not cancel, and Run returns. +func TestHelperRun_StopsOnTheRunContext(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "orders") + h := newBlocking() + helper := NewHelperWithOptions[cloudevents.Event](client, h.handle, options("orders")) + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(runCtx) }() + + publish(t, ctx, client, "orders", `{"item":"book"}`) + select { + case <-h.started: + case err := <-ran: + t.Fatalf("Run returned (%v) before the stop", err) + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the message") + } + + cancel() + select { + case err := <-ran: + t.Fatalf("Run returned (%v) while the handler held a message", err) + case <-time.After(200 * time.Millisecond): + } + + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Run did not return after the handler finished") + } + require.EqualValues(t, 1, h.handled.Load()) + require.False(t, h.cancelled.Load(), "the stop cancelled the context of the handler") +} + +// TestHelperRun_StopsRetryingOnTheRunContext: a handler that fails while the +// app stops is not retried; the message goes back to Pub/Sub. +func TestHelperRun_StopsRetryingOnTheRunContext(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "orders") + h := newBlocking() + h.fail = true + helper := NewHelperWithOptions[cloudevents.Event](client, h.handle, options("orders")) + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + ran := make(chan error, 1) + go func() { ran <- helper.Run(runCtx) }() + + publish(t, ctx, client, "orders", `{"item":"book"}`) + select { + case <-h.started: + case <-time.After(5 * time.Second): + t.Fatal("the handler never took the message") + } + cancel() + close(h.release) + select { + case err := <-ran: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("Run kept retrying after the stop") + } + require.EqualValues(t, 1, h.handled.Load()) +} + +// TestHelperRun_FailsWhenASubscriptionIsMissing: one of the subscriptions +// does not exist (#205). Run stops the others and returns an error naming +// it, so the app fails its boot instead of running without it. +func TestHelperRun_FailsWhenASubscriptionIsMissing(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "orders") + helper := NewHelperWithOptions[cloudevents.Event](client, newBlocking().handle, options("orders", "missing")) + + ran := make(chan error, 1) + go func() { ran <- helper.Run(ctx) }() + select { + case err := <-ran: + require.ErrorContains(t, err, "pubsub: could not receive on missing") + case <-time.After(5 * time.Second): + t.Fatal("Run kept running without subscription missing") + } +} + +// TestNew_RunsUntilTheCommandContextIsDone: the gcp_pubsub command runs the +// helper, with the subscriptions of config, until the context cobra +// executes it with is done (#205), instead of returning at once. +func TestNew_RunsUntilTheCommandContextIsDone(t *testing.T) { + boost.Start() + client, ctx := setup(t) + subscription(t, ctx, client, "changeme") + cmd := New[cloudevents.Event](client)(newBlocking().handle) + require.Equal(t, "gcp_pubsub", cmd.Name()) + + runCtx, cancel := context.WithCancel(ctx) + defer cancel() + cmd.SetArgs([]string{}) + done := make(chan error, 1) + go func() { done <- cmd.ExecuteContext(runCtx) }() + select { + case err := <-done: + t.Fatalf("the command returned (%v) before its context was done", err) + case <-time.After(300 * time.Millisecond): + } + + cancel() + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(5 * time.Second): + t.Fatal("the command did not stop with its context") + } +} diff --git a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/subscriber.go b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/subscriber.go index 30924422..889414d1 100644 --- a/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/subscriber.go +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/subscriber.go @@ -29,10 +29,10 @@ func NewSubscriber[T any](client *pubsub.Client, handler function.Handler[T], su } } -// Subscribe subscribes and consumes messages from multiple Pub/Sub topics concurrently +// Subscribe receives the messages of the subscription until ctx is done, and +// returns once the handlers finished the ones they hold. It returns an error +// naming the subscription when it cannot be received. func (l *Subscriber[T]) Subscribe(ctx context.Context) error { - logger := log.FromContext(ctx).WithTypeOf(l) - logger.Infof("pubsub - Subscribing to %s", l.subscription) subscription := l.client.Subscriber(l.subscription) subscription.ReceiveSettings.MaxOutstandingMessages = int(l.options.Concurrency) @@ -49,10 +49,10 @@ func (l *Subscriber[T]) Subscribe(ctx context.Context) error { msg.Ack() }) if err != nil { - logger.Fatalf("Failed to start subscription %s: %v", l.subscription, err) + return errors.Wrap(err, errors.Internalf("pubsub: could not receive on %s", l.subscription)) } - return err + return nil } // processMessage processes each message, retries if needed, and applies backoff @@ -68,7 +68,9 @@ func (l *Subscriber[T]) processMessage(ctx context.Context, msg *pubsub.Message) } for { - if _, err := l.handler(ctx, in); err != nil { + // The stop of the app cancels ctx: the handler finishes the message it + // holds instead of failing on it. + if _, err := l.handler(context.WithoutCancel(ctx), in); err != nil { retryCount++ logger.WithField("payload", msg.Data).Warnf("msgID=%s handler failed (attempt %d/%d): %v", msg.ID, retryCount, l.options.RetryLimit, err) @@ -76,6 +78,11 @@ func (l *Subscriber[T]) processMessage(ctx context.Context, msg *pubsub.Message) return errors.Wrap(err, errors.Internalf("max retry limit reached")) } + // The app is stopping: give the message back instead of retrying + if ctx.Err() != nil { + return errors.Wrap(err, errors.Internalf("stopped before a retry")) + } + // Apply backoff if enabled if l.options.Backoff { l.applyBackoff(retryCount) diff --git a/builder/README.md b/builder/README.md index af796b5b..68f69a09 100644 --- a/builder/README.md +++ b/builder/README.md @@ -145,6 +145,9 @@ func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevent own keys (`.enabled`, the pubsub `version`) stay as they are. The app runs the chosen adapter with no CLI argument and no `boost.bootstrap.function.default`. +- The pubsub adapter on v1 (`boost.bootstrap.function.adapter.pubsub.version=v1`) + reads with its own v1 `*pubsub.Client`, built from the + `boost.factory.gcp.pubsub` keys; the app's default client is v2. - Each middleware is the fx module under `fx/modules/bootstrap/function/middleware` of the [`bootstrap/function/middleware`](../bootstrap/function/middleware) of that @@ -379,7 +382,10 @@ default list), run the function handler, boot the worker with each adapter (nats on an embedded server) and with an unknown one (the boot fails), run the generated main with only `WORKER_FUNCTION_ADAPTER=nats` until it records an order published on NATS and then send it SIGTERM (it exits 0 within a -second), and fail when `gen/` or a main is stale. +second), run it with only `WORKER_FUNCTION_ADAPTER=pubsub`, v2 and v1, against +a `pstest` fake until it records a CloudEvent and a raw message, stays up +and exits 0 on SIGTERM, and with no subscription (the boot fails naming it), +and fail when `gen/` or a main is stale. ## Non-goals diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index ed37ce80..580c4160 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -12,6 +12,7 @@ import ( "testing" "time" + "cloud.google.com/go/pubsub" "cloud.google.com/go/pubsub/pstest" cloudevents "github.com/cloudevents/sdk-go/v2" natsserver "github.com/nats-io/nats-server/v2/test" @@ -30,6 +31,9 @@ import ( functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" "github.com/xgodev/boost/model/errors" "go.uber.org/fx" + "google.golang.org/api/option" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" ) // TestMain boots boost: the function and its adapter read their config. @@ -217,6 +221,128 @@ func recordOrder(t *testing.T) (*exec.Cmd, *syncBuffer, <-chan error) { return cmd, out, exited } +// versionEnv chooses the Pub/Sub adapter version +// (boost.bootstrap.function.adapter.pubsub.version, v2 by default). +const versionEnv = "BOOST_BOOTSTRAP_FUNCTION_ADAPTER_PUBSUB_VERSION" + +// pubsubFake runs a Pub/Sub fake with topic orders and, when sub is not +// empty, subscription sub on it; changeme is the subscription the adapter +// reads by default. +func pubsubFake(t *testing.T, sub string) (*pstest.Server, *pubsub.Topic) { + t.Helper() + fake := pstest.NewServer() + t.Cleanup(func() { _ = fake.Close() }) + + ctx := context.Background() + client, err := pubsub.NewClient(ctx, "example", + option.WithEndpoint(fake.Addr), option.WithoutAuthentication(), + option.WithGRPCDialOption(grpc.WithTransportCredentials(insecure.NewCredentials()))) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + topic, err := client.CreateTopic(ctx, "orders") + require.NoError(t, err) + t.Cleanup(topic.Stop) + if sub != "" { + _, err = client.CreateSubscription(ctx, sub, pubsub.SubscriptionConfig{Topic: topic, AckDeadline: 10 * time.Second}) + require.NoError(t, err) + } + return fake, topic +} + +// startPubsubWorker runs the generated main of app worker with only +// WORKER_FUNCTION_ADAPTER=pubsub (plus env) against fake, and returns it, +// its output and the channel its exit goes to. +func startPubsubWorker(t *testing.T, fake *pstest.Server, env ...string) (*exec.Cmd, *syncBuffer, <-chan error) { + t.Helper() + out := &syncBuffer{} + cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(append(os.Environ(), adapterEnv+"=pubsub", driverEnv+"=noop", + emulatorEnv+"="+fake.Addr, projectEnv+"=example", mainEnv+"=1"), env...) + cmd.Stdout, cmd.Stderr = out, out + require.NoError(t, cmd.Start()) + exited := make(chan error, 1) + go func() { exited <- cmd.Wait() }() + t.Cleanup(func() { _ = cmd.Process.Kill() }) + return cmd, out, exited +} + +// TestMain_HandlesPubSubMessages runs the generated main of app worker on +// the pubsub adapter, v2 and v1 (#205): it boots, stays up, records the +// order of a CloudEvent and of a raw message (wrapped as the event data), +// and exits 0 on SIGTERM. +func TestMain_HandlesPubSubMessages(t *testing.T) { + for _, version := range []string{"v2", "v1"} { + t.Run(version, func(t *testing.T) { + fake, topic := pubsubFake(t, "changeme") + cmd, out, exited := startPubsubWorker(t, fake, versionEnv+"="+version) + + // a CloudEvent in the adapter's binary mode (ce_* attributes) and a + // raw message the adapter wraps as the data of an event + ctx := context.Background() + for _, msg := range []*pubsub.Message{ + {Data: []byte(`{"item":"book"}`), Attributes: map[string]string{ + "ce_specversion": "1.0", "ce_id": "evt-1", "ce_source": "test", + "ce_type": "order.created", "content-type": "application/json", + }}, + {Data: []byte(`{"item":"pen"}`)}, + } { + _, err := topic.Publish(ctx, msg).Get(ctx) + require.NoError(t, err) + } + + deadline := time.After(10 * time.Second) + tick := time.NewTicker(50 * time.Millisecond) + defer tick.Stop() + for !strings.Contains(out.String(), "of book") || !strings.Contains(out.String(), "of pen") { + select { + case <-tick.C: + case err := <-exited: + t.Fatalf("the worker exited (%v) before handling both orders: %s", err, out.String()) + case <-deadline: + t.Fatalf("the worker never handled both orders: %s", out.String()) + } + } + + select { + case err := <-exited: + t.Fatalf("the worker exited (%v) after handling the orders: %s", err, out.String()) + case <-time.After(300 * time.Millisecond): + } + + require.NoError(t, cmd.Process.Signal(syscall.SIGTERM)) + select { + case err := <-exited: + require.NoError(t, err, "the worker did not exit 0: %s", out.String()) + case <-time.After(5 * time.Second): + t.Fatalf("the worker did not stop on SIGTERM: %s", out.String()) + } + }) + } +} + +// TestMain_MissingSubscriptionFailsTheBoot runs the generated main of app +// worker on the pubsub adapter, v2 and v1, with no subscription changeme on +// the fake (#205): the boot fails with a non-zero exit and an error naming +// it, instead of a log line and a worker that reads nothing. +func TestMain_MissingSubscriptionFailsTheBoot(t *testing.T) { + for _, version := range []string{"v2", "v1"} { + t.Run(version, func(t *testing.T) { + fake, _ := pubsubFake(t, "") + _, out, exited := startPubsubWorker(t, fake, versionEnv+"="+version) + + select { + case err := <-exited: + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "the worker exited 0 without its subscription: %s", out.String()) + assert.Contains(t, out.String(), "pubsub: could not receive on changeme") + case <-time.After(10 * time.Second): + t.Fatalf("the worker kept running without its subscription: %s", out.String()) + } + }) + } +} + // syncBuffer is a bytes.Buffer the child process and the test share. type syncBuffer struct { mu sync.Mutex diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index 6b1b7bb8..aee3da86 100644 --- a/fx/modules/bootstrap/function/README.md +++ b/fx/modules/bootstrap/function/README.md @@ -64,11 +64,18 @@ fx.Provide(fx.Annotate( - **Run ends on its own** (no stop requested) — the app is shut down with exit code 0 on success, or 1 when `Run` returned an error (logged). -The `nats`, `cloudevents_nats` and `gcp_pubsub` (v1) adapters run on that -context (`Helper.Run(cmd.Context())`): when it is cancelled they take no new -message, let the handler finish the one it holds, with a context the stop -does not cancel, and return, so SIGTERM stops the app with exit code 0 well -before the stop timeout. NATS drains its subscriptions; Pub/Sub v1 gives back -a failed message instead of retrying it. A NATS subject it cannot subscribe -to fails `Run`. `Helper.Start()` is deprecated: it runs with a context that -is never cancelled. +The `nats`, `cloudevents_nats` and `gcp_pubsub` (v1 and v2) adapters run on +that context (`Helper.Run(cmd.Context())`) and block until it is done: when +it is cancelled they take no new message, let the handler finish the one it +holds, with a context the stop does not cancel, and return, so SIGTERM stops +the app with exit code 0 well before the stop timeout. NATS drains its +subscriptions; Pub/Sub gives back a failed message instead of retrying it. A +NATS subject it cannot subscribe to, or a Pub/Sub subscription it cannot +receive on (`pubsub: could not receive on `, e.g. one that does not +exist), fails `Run`, so the boot fails with exit code 1. `Helper.Start()` is +deprecated: it runs with a context that is never cancelled. + +The Pub/Sub v1 adapter module provides the v1 `*pubsub.Client` +(`fx/modules/factory/contrib/cloud.google.com/pubsub/v1.ClientModule()`) +whatever `boost.factory.gcp.pubsub.version` says, so +`boost.bootstrap.function.adapter.pubsub.version=v1` alone turns it on. diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/config_test.go b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/config_test.go index e8fdb907..4031a77b 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/config_test.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/config_test.go @@ -63,8 +63,11 @@ func TestModuleDisabledProvidesNothing(t *testing.T) { assert.Empty(t, cmds) } +// TestModuleGraphIsComplete loads the adapter on v1 with the factory on its +// v2 default, as a builder app does (#205): the module still provides the v1 +// *pubsub.Client the adapter reads with. func TestModuleGraphIsComplete(t *testing.T) { - load(t, "true", "v1", "v1") + load(t, "true", "v1", "v2") assert.NoError(t, fx.ValidateApp( Module[string](), diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/module.go b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/module.go index 6832b0a9..ead3eeec 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/module.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/module.go @@ -25,7 +25,7 @@ func Module[T any]() fx.Option { once.Do(func() { options = fx.Options( - fxpubsub.Module(), + fxpubsub.ClientModule(), fx.Provide( fx.Annotated{ Group: function.BSFunctionAdaptersGroupKey, diff --git a/fx/modules/factory/contrib/cloud.google.com/pubsub/v1/module.go b/fx/modules/factory/contrib/cloud.google.com/pubsub/v1/module.go index a0811071..8aaae174 100644 --- a/fx/modules/factory/contrib/cloud.google.com/pubsub/v1/module.go +++ b/fx/modules/factory/contrib/cloud.google.com/pubsub/v1/module.go @@ -17,20 +17,27 @@ func Module() fx.Option { return fx.Error(err) } - options := fx.Options() if !IsEnabled() { - return options + return fx.Options() } - once.Do(func() { + return ClientModule() +} +// ClientModule provides the v1 *pubsub.Client whatever +// boost.factory.gcp.pubsub.version says. Modules that read with the v1 client, +// like the v1 function adapter, include it. It shares Module's once, so the +// client is provided a single time when both are included. +func ClientModule() fx.Option { + options := fx.Options() + + once.Do(func() { options = fx.Options( contextfx.Module(), fx.Provide( pubsub.NewClient, ), ) - }) return options From e1b16a7db358b4dbf3a83d86ee795236d89e21c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 11:32:29 -0300 Subject: [PATCH 09/11] feat(inject): generate only the publisher drivers the app uses (#207) @Inject(index=N, drivers=[a, b]) on a *publisher.Publisher parameter limits the generated publisher to the listed drivers plus noop. Consumers without drivers keep all of them. The builder example worker uses nats and pubsub and no longer links confluent-kafka-go. --- builder/README.md | 8 +- .../examples/simple/cmd/worker/main_test.go | 30 ++++++++ .../v2/kafka/default_producer.go | 52 ------------- .../boost/wrapper/publisher/publisher.go | 8 +- .../wrapper/publisher/publisher_kafka.go | 18 ----- .../publisher/publisher_kafka_nocgo.go | 16 ---- .../examples/simple/ordercreated/handler.go | 5 +- inject/README.md | 16 +++- inject/client.go | 13 +++- inject/graph.go | 31 ++++++++ inject/publisher.go | 56 ++++++++++++-- inject/publisher_test.go | 75 +++++++++++++++++++ inject/spec.go | 5 +- .../publisher/drivers/publisher.go.golden | 66 ++++++++++++++++ 14 files changed, 294 insertions(+), 105 deletions(-) delete mode 100644 builder/examples/simple/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka/default_producer.go delete mode 100644 builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go delete mode 100644 builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go create mode 100644 inject/testdata/publisher/drivers/publisher.go.golden diff --git a/builder/README.md b/builder/README.md index 68f69a09..be27c167 100644 --- a/builder/README.md +++ b/builder/README.md @@ -371,7 +371,9 @@ mains, _ := doors.RenderCmd(ctx) // /main.go relative to CmdDir `BUILDER_EXAMPLES_SIMPLE_STOCK_MEMORY_SOLD__OUT`, default `sold-out`); - `worker`: the `ordercreated` function on the cloudevents, the nats or the pubsub adapter, chosen by `worker.function.adapter` (`WORKER_FUNCTION_ADAPTER`, - default `cloudevents`), behind the `recovery` and `logger` middlewares. + default `cloudevents`), behind the `recovery` and `logger` middlewares; its + handler injects the publisher with `drivers=[nats, pubsub]`, so the worker + links no Kafka client. `cmd/api/main.go` and `cmd/worker/main.go` are generated. Its tests call the routes, the gRPC service and the MCP tool of the generated app, check that the handler @@ -385,7 +387,9 @@ an order published on NATS and then send it SIGTERM (it exits 0 within a second), run it with only `WORKER_FUNCTION_ADAPTER=pubsub`, v2 and v1, against a `pstest` fake until it records a CloudEvent and a raw message, stays up and exits 0 on SIGTERM, and with no subscription (the boot fails naming it), -and fail when `gen/` or a main is stale. +build the worker and check with `go version -m` that it has no +`confluent-kafka-go`, boot it with the kafka driver (the boot fails listing +`nats, noop, pubsub`), and fail when `gen/` or a main is stale. ## Non-goals diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index 580c4160..b5912fc9 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -151,6 +151,36 @@ func TestMain_BadConfigExitsWithTheReason(t *testing.T) { assert.Contains(t, string(out), `unknown adapter "nope"`) } +// TestMain_LinksOnlyTheDriversItUses builds the generated main of app worker, +// whose handler injects the publisher with drivers=[nats, pubsub] (#207): the +// binary has no confluent-kafka-go, so it needs no librdkafka nor cgo for it. +func TestMain_LinksOnlyTheDriversItUses(t *testing.T) { + bin := t.TempDir() + "/worker" + out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput() + require.NoError(t, err, "go build: %s", out) + + out, err = exec.Command("go", "version", "-m", bin).CombinedOutput() + require.NoError(t, err, "go version -m: %s", out) + assert.Contains(t, string(out), "github.com/nats-io/nats.go") + assert.NotContains(t, string(out), "confluent-kafka-go", "the worker links the kafka driver") +} + +// TestMain_UnknownDriverListsTheGenerated runs the generated main of app +// worker with the kafka driver, which drivers=[nats, pubsub] does not +// generate (#207): the boot fails naming the drivers the app has. +func TestMain_UnknownDriverListsTheGenerated(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestMainChild$") + cmd.Env = append(os.Environ(), driverEnv+"=kafka", mainEnv+"=1") + out, err := cmd.CombinedOutput() + + require.NoError(t, ctx.Err(), "the worker booted with the kafka driver: %s", out) + var exit *exec.ExitError + require.ErrorAs(t, err, &exit, "the main must fail: %s", out) + assert.Contains(t, string(out), `unknown driver "kafka"; valid: nats, noop, pubsub`) +} + // TestMain_RunsTheChosenAdapter runs the generated main of app worker with // only WORKER_FUNCTION_ADAPTER=nats set (#203): no CLI argument and no // boost.bootstrap.function.default. The worker subscribes and records the diff --git a/builder/examples/simple/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka/default_producer.go b/builder/examples/simple/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka/default_producer.go deleted file mode 100644 index d260b1e0..00000000 --- a/builder/examples/simple/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka/default_producer.go +++ /dev/null @@ -1,52 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -//go:build cgo - -package kafka - -import ( - "context" - "sync" - - "go.uber.org/fx" - - client "github.com/confluentinc/confluent-kafka-go/v2/kafka" - factory "github.com/xgodev/boost/factory/contrib/confluentinc/confluent-kafka-go/v2" - contextfx "github.com/xgodev/boost/fx/modules/core/context" -) - -// defaultProducerRoot is the config root of the default client (*github.com/confluentinc/confluent-kafka-go/v2/kafka.Producer); -// env BOOST_FACTORY_CONFLUENT_*. -const defaultProducerRoot = "boost.factory.confluent" - -func init() { - factory.ConfigAdd(defaultProducerRoot) -} - -var defaultProducerOnce sync.Once - -// DefaultProducerModule provides the default client (*github.com/confluentinc/confluent-kafka-go/v2/kafka.Producer), built from the keys under -// boost.factory.confluent without plugins, and closes it when the app stops. -// It returns an empty option after the first call, so every consumer can -// include it. -func DefaultProducerModule() fx.Option { - options := fx.Options() - - defaultProducerOnce.Do(func() { - options = fx.Module("github.com/confluentinc/confluent-kafka-go/v2/kafka.DefaultProducer", - contextfx.Module(), - fx.Provide(newDefaultProducer), - ) - }) - - return options -} - -func newDefaultProducer(ctx context.Context, lc fx.Lifecycle) (*client.Producer, error) { - c, err := factory.NewProducerWithConfigPath(ctx, defaultProducerRoot) - if err != nil { - return nil, err - } - lc.Append(fx.StopHook(c.Close)) - return c, nil -} diff --git a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go index 8558da96..51853dbe 100644 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go +++ b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go @@ -22,7 +22,7 @@ import ( const publisherDriverKey = "boost.wrapper.publisher.driver.name" func init() { - config.Add(publisherDriverKey, "noop", "driver of the publisher inject builds: kafka, nats, noop, pubsub") + config.Add(publisherDriverKey, "noop", "driver of the publisher inject builds: nats, noop, pubsub") } // PublisherDriver returns the name of the driver PublisherModule wires. @@ -34,7 +34,7 @@ var publisherOnce sync.Once // PublisherModule provides the *publisher.Publisher of // github.com/xgodev/boost/wrapper/publisher with the driver named by -// boost.wrapper.publisher.driver.name (kafka, nats, noop, pubsub; default noop) +// boost.wrapper.publisher.driver.name (nats, noop, pubsub; default noop) // and only that driver and its client. An unknown name fails the boot. It // returns an empty option after the first call, so every consumer can include // it. @@ -43,8 +43,6 @@ func PublisherModule() fx.Option { publisherOnce.Do(func() { switch name := PublisherDriver(); name { - case "kafka": - options = publisherModule(kafkaDriver()...) case "nats": options = publisherModule(client_nats_5da165e5.DefaultConnModule(), fx.Provide(fx.Private, driver_nats_3a6a55c7.New)) case "noop": @@ -52,7 +50,7 @@ func PublisherModule() fx.Option { case "pubsub": options = publisherModule(client_pubsub_0cd2c75d.DefaultClientModule(), fx.Provide(fx.Private, driver_pubsub_d9085a5a.New)) default: - options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "kafka, nats, noop, pubsub")) + options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "nats, noop, pubsub")) } }) diff --git a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go deleted file mode 100644 index d8a2d836..00000000 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go +++ /dev/null @@ -1,18 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -//go:build cgo - -package publisher - -import ( - "go.uber.org/fx" - - client_kafka_0b369746 "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka" - driver_confluent_f4b0f64e "github.com/xgodev/boost/wrapper/publisher/driver/contrib/confluentinc/confluent-kafka-go/v2" -) - -// kafkaDriver returns the options wiring the kafka driver and its -// client, which builds only with cgo. -func kafkaDriver() []fx.Option { - return []fx.Option{client_kafka_0b369746.DefaultProducerModule(), fx.Provide(fx.Private, driver_confluent_f4b0f64e.New)} -} diff --git a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go deleted file mode 100644 index c3b2dbfe..00000000 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go +++ /dev/null @@ -1,16 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -//go:build !cgo - -package publisher - -import ( - "github.com/xgodev/boost/model/errors" - "go.uber.org/fx" -) - -// kafkaDriver fails the boot: the client of the kafka driver builds -// only with cgo, and this build has none. -func kafkaDriver() []fx.Option { - return []fx.Option{fx.Error(errors.NotSupportedf("%s: driver %q is not built in: it needs %s", publisherDriverKey, "kafka", "cgo"))} -} diff --git a/builder/examples/simple/ordercreated/handler.go b/builder/examples/simple/ordercreated/handler.go index 289b99d7..dd44bff3 100644 --- a/builder/examples/simple/ordercreated/handler.go +++ b/builder/examples/simple/ordercreated/handler.go @@ -23,9 +23,10 @@ type Handler struct { // New provides the handler; the generated function door gets it from here. // The publisher announces the recorded orders, on the broker the function -// may read from. +// may read from; only the nats and pubsub drivers (and noop, the default) +// are generated, so the worker links no Kafka client nor librdkafka. // @Provide(index=0) -// @Inject(index=1) +// @Inject(index=1, drivers=[nats, pubsub]) func New(svc *order.Service, pub *publisher.Publisher) *Handler { return &Handler{svc: svc, pub: pub} } diff --git a/inject/README.md b/inject/README.md index 4fb02022..3ac8455d 100644 --- a/inject/README.md +++ b/inject/README.md @@ -17,6 +17,7 @@ function plus a root `Module()` that wires the whole application. | `@Inject(name=X, index=N)` | `@Provide`/`@Invoke` function | parameter `N` receives the named value `X` | | `@Inject(group=X, index=N)` | `@Provide`/`@Invoke` function | parameter `N` (a slice `[]T`) receives every `T` of group `X` | | `@Inject(name=X, index=N, plugins=[a, b])` | `@Provide`/`@Invoke` function | parameter `N`, a factory client (`*resty.Client`, `*nats.Conn`, `*gocql.Session`, ...), is built by the generator with the plugins `a` and `b` (see below) | +| `@Inject(index=N, drivers=[a, b])` | `@Provide`/`@Invoke` function | parameter `N`, a `*publisher.Publisher`, is built by the generator with only the drivers `a` and `b` (see [Publisher](#publisher)) | | `@Invoke` | function | the function runs when the application starts (`fx.Invoke`) | | `@Default` | `@Provide` constructor | default implementation of a value with two or more providers; config picks one (see below) | | `@Config` | struct | the struct's fields are config keys; the generator registers them and provides `*Struct` (see below) | @@ -339,9 +340,20 @@ and its client: the app and the publisher share it, with the plugins the app lists. The adapter of a builder `@Function` shares it too (`GeneratorOptions.DefaultClients`). +- **Drivers.** `@Inject(index=0, drivers=[nats, pubsub])` generates only + those drivers and their clients, plus `noop`, the default; without + `drivers` all of them are. The generated drivers are the union of what the + consumers list, and all of them when one consumer lists none. A driver not + generated is an unknown value for the key: the boot fails listing the + generated ones (`valid: nats, noop, pubsub`). An app that leaves `kafka` + out links neither `confluent-kafka-go` nor librdkafka. An unknown name, + `drivers=[]`, or `drivers` on a parameter that is not + `*publisher.Publisher` (or on `@Provide`) fails the generator with + `NotValid`. - **cgo.** The kafka client binds librdkafka through cgo, so its files build only with cgo (`//go:build cgo`). An app built with `CGO_ENABLED=0` still - builds; choosing `kafka` there fails the boot with `NotSupported`. + builds; choosing `kafka` there fails the boot with `NotSupported`. Leave + `kafka` out of `drivers` to keep cgo and librdkafka out of the app. - **The app wins.** When a `@Provide` of the app provides `*publisher.Publisher`, it is used and nothing is generated. A named publisher (`name=x`) is not built: provide it. `plugins` do not apply. @@ -379,6 +391,8 @@ The generator fails, without touching the output directory, when: - `plugins` is on a `@Provide`, on a parameter that is not a factory client or that the app provides, or with `group`; lists an unknown or repeated plugin; or differs between two consumers of one client; +- `drivers` is on a `@Provide` or on a parameter that is not + `*publisher.Publisher`, is empty, or lists an unknown or repeated driver; - a client name is not lowerCamel, or two clients would be written to the same file (`name=default` and the unnamed client); - the dependencies have a cycle, or a function depends on what it provides; diff --git a/inject/client.go b/inject/client.go index 6ef29354..d969100d 100644 --- a/inject/client.go +++ b/inject/client.go @@ -199,6 +199,13 @@ func pointerTo(entry annotation.Entry, tp string) (string, bool) { // parsePlugins reads the plugins attribute of @Inject: "[a, b]" or a single // name. It returns nil when raw is empty and a non-nil empty list for "[]". func parsePlugins(raw string) ([]string, error) { + return parseList("plugins", raw) +} + +// parseList reads the list attribute attr of an annotation: "[a, b]" or a +// single name. It returns nil when raw is empty and a non-nil empty list for +// "[]". +func parseList(attr, raw string) ([]string, error) { raw = strings.TrimSpace(raw) if raw == "" { return nil, nil @@ -206,7 +213,7 @@ func parsePlugins(raw string) ([]string, error) { open, closed := strings.HasPrefix(raw, "["), strings.HasSuffix(raw, "]") if open != closed { - return nil, errors.NotValidf("plugins=%s: write the list as [a, b]", raw) + return nil, errors.NotValidf("%s=%s: write the list as [a, b]", attr, raw) } if open { raw = strings.TrimSpace(raw[1 : len(raw)-1]) @@ -221,9 +228,9 @@ func parsePlugins(raw string) ([]string, error) { name = strings.TrimSpace(name) switch { case name == "": - return nil, errors.NotValidf("plugins=[%s] has an empty name", raw) + return nil, errors.NotValidf("%s=[%s] has an empty name", attr, raw) case seen[name]: - return nil, errors.NotValidf("plugins=[%s] lists %s twice", raw, name) + return nil, errors.NotValidf("%s=[%s] lists %s twice", attr, raw, name) } seen[name] = true names = append(names, name) diff --git a/inject/graph.go b/inject/graph.go index c6d04045..44e8d0ce 100644 --- a/inject/graph.go +++ b/inject/graph.go @@ -331,6 +331,9 @@ func validateProvides(entry annotation.Entry, provides map[int]Annotation) error if ann.Plugins != "" { return errors.NotValidf("@Provide on %s result %d cannot have plugins: plugins go on the @Inject of a client parameter", gid(entry), idx) } + if ann.Drivers != "" { + return errors.NotValidf("@Provide on %s result %d cannot have drivers: drivers go on the @Inject of a *publisher.Publisher parameter", gid(entry), idx) + } } return nil } @@ -362,6 +365,34 @@ func validateInjects(entry annotation.Entry, injects map[int]Annotation) error { if err := validatePlugins(entry, idx, ann); err != nil { return err } + if err := validateDrivers(entry, idx, ann); err != nil { + return err + } + } + return nil +} + +// validateDrivers checks the drivers of an @Inject: the parameter is a +// *publisher.Publisher and every driver is one inject generates. +func validateDrivers(entry annotation.Entry, idx int, ann Annotation) error { + if ann.Drivers == "" { + return nil + } + + tp := entry.Func.Parameters[idx].Type + if !isPublisher(entry, tp) { + return errors.NotValidf("@Inject on %s parameter %d has drivers, but %s is not *publisher.Publisher", gid(entry), idx, tp) + } + + drivers, err := parseDrivers(ann.Drivers) + if err != nil { + return errors.NewNotValid(err, fmt.Sprintf("@Inject on %s parameter %d", gid(entry), idx)) + } + for _, name := range drivers { + if _, ok := publisherDriver(name); !ok { + return errors.NotValidf("@Inject on %s parameter %d: the publisher has no driver %s; its drivers are %s", + gid(entry), idx, name, publisherDriverNames()) + } } return nil } diff --git a/inject/publisher.go b/inject/publisher.go index 11e0b7bf..0fff9a84 100644 --- a/inject/publisher.go +++ b/inject/publisher.go @@ -54,10 +54,37 @@ func publisherDriverNames() string { return strings.Join(names, ", ") } +// publisherDriver returns the driver named name. +func publisherDriver(name string) (PublisherDriver, bool) { + for _, d := range publisherDrivers { + if d.Name == name { + return d, true + } + } + return PublisherDriver{}, false +} + +// parseDrivers reads the drivers attribute of @Inject: "[a, b]" or a single +// name. A list without names is NotValid: the publisher needs a driver. +func parseDrivers(raw string) ([]string, error) { + names, err := parseList("drivers", raw) + if err != nil { + return nil, err + } + if len(names) == 0 { + return nil, errors.NotValidf("drivers=[] lists no driver: omit it to generate all of them (%s)", publisherDriverNames()) + } + return names, nil +} + // Publisher is the *publisher.Publisher inject builds: one for the // application, with the driver PublisherDriverKey names. type Publisher struct { Consumers []string // keys of the components injecting it, sorted + // Drivers are the names of the drivers generated, in the order of + // publisherDrivers: the ones the consumers list, plus DefaultPublisherDriver; + // all of them when a consumer lists none. + Drivers []string } // PublisherOf returns the publisher inject builds: its consumers are the @@ -67,6 +94,8 @@ func PublisherOf(g *graph.Graph[Component]) Publisher { provided := providedValues(g) var p Publisher + listed := map[string]bool{DefaultPublisherDriver: true} + all := false for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Injects) { @@ -81,6 +110,21 @@ func PublisherOf(g *graph.Graph[Component]) Publisher { if !slices.Contains(p.Consumers, key) { p.Consumers = append(p.Consumers, key) } + // validateDrivers checked the names when the graph was built + drivers, _ := parseDrivers(ann.Drivers) + all = all || ann.Drivers == "" + for _, name := range drivers { + listed[name] = true + } + } + } + + if len(p.Consumers) == 0 { + return p + } + for _, d := range publisherDrivers { + if all || listed[d.Name] { + p.Drivers = append(p.Drivers, d.Name) } } return p @@ -121,13 +165,15 @@ func (p Publisher) constrainedFile(d PublisherDriver, constraint string) (string // renderPublisher adds the files of the publisher to files and returns the // default clients of its drivers that clients lacks: the generated package -// of a driver's client holds the module the publisher wires. A driver whose -// client builds only under a constraint goes in a file of its own, with a -// fallback that fails the boot when the driver is chosen. +// of a driver's client holds the module the publisher wires. Only the drivers +// of p are generated, so an app links no other driver nor its client. A +// driver whose client builds only under a constraint goes in a file of its +// own, with a fallback that fails the boot when the driver is chosen. func renderPublisher(tmpl *template.Template, base string, p Publisher, clients []Client, files map[string][]byte) ([]Client, error) { - data := PublisherData{Key: PublisherDriverKey, Default: DefaultPublisherDriver, Names: publisherDriverNames()} + data := PublisherData{Key: PublisherDriverKey, Default: DefaultPublisherDriver, Names: strings.Join(p.Drivers, ", ")} var missing []Client - for _, d := range publisherDrivers { + for _, name := range p.Drivers { + d, _ := publisherDriver(name) drv := ImportData{Alias: Alias("driver", d.Package, d.Path), Path: d.Path} imports := []ImportData{drv} var options []string diff --git a/inject/publisher_test.go b/inject/publisher_test.go index 0d5499cb..cffd75c4 100644 --- a/inject/publisher_test.go +++ b/inject/publisher_test.go @@ -2,6 +2,7 @@ package inject import ( "context" + "fmt" "strings" "testing" @@ -148,3 +149,77 @@ func TestGenerator_RenderPublisherSharesTheClient(t *testing.T) { "the nats driver gets the default *nats.Conn the app injects") assert.Equal(t, 1, strings.Count(string(files["module.go"]), call+","), "the root includes the app's client once") } + +func TestPublisherOf_Drivers(t *testing.T) { + all := []string{"kafka", "nats", "noop", "pubsub"} + testCases := []struct { + name string + consumers []string // the @Inject of each consumer + want []string + }{ + {name: "all by default", consumers: []string{"index=0"}, want: all}, + {name: "the listed ones and noop, the default", consumers: []string{"index=0, drivers=[pubsub, nats]"}, want: []string{"nats", "noop", "pubsub"}}, + {name: "a single name", consumers: []string{"index=0, drivers=nats"}, want: []string{"nats", "noop"}}, + {name: "noop alone", consumers: []string{"index=0, drivers=[noop]"}, want: []string{"noop"}}, + {name: "the union of the consumers", consumers: []string{"index=0, drivers=[nats]", "index=0, drivers=[pubsub]"}, want: []string{"nats", "noop", "pubsub"}}, + {name: "a consumer without drivers takes all", consumers: []string{"index=0, drivers=[nats]", "index=0"}, want: all}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + var entries []annotation.Entry + for i, c := range tc.consumers { + entries = append(entries, publisherConsumer(fmt.Sprintf("consumer%d", i), inject(c))) + } + assert.Equal(t, tc.want, PublisherOf(newTestGraph(t, entries)).Drivers) + }) + } + + t.Run("no publisher", func(t *testing.T) { + assert.Empty(t, PublisherOf(newTestGraph(t, []annotation.Entry{publisherConsumer("order")})).Drivers) + }) +} + +func TestPublisherOf_DriversRules(t *testing.T) { + stream := at("stream", "New", []string{"*nats.Conn"}, []string{"*Stream"}, provide("index=0"), inject("index=0, drivers=[nats]")) + stream.Imports = map[string]string{"nats": natsPath} + provider := at("events", "NewPublisher", nil, []string{"*publisher.Publisher"}, provide("index=0, drivers=[nats]")) + provider.Imports = map[string]string{"publisher": publisherPath} + + testCases := []struct { + name string + entry annotation.Entry + parts []string + }{ + {name: "unknown driver", entry: publisherConsumer("order", inject("index=0, drivers=[nats, sqs]")), + parts: []string{"sqs", "kafka, nats, noop, pubsub"}}, + {name: "empty list", entry: publisherConsumer("order", inject("index=0, drivers=[]")), parts: []string{"drivers=[]"}}, + {name: "listed twice", entry: publisherConsumer("order", inject("index=0, drivers=[nats, nats]")), parts: []string{"nats twice"}}, + {name: "not a publisher", entry: stream, parts: []string{"drivers", "*nats.Conn", "*publisher.Publisher"}}, + {name: "on @Provide", entry: provider, parts: []string{"@Provide", "drivers"}}, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := NewGraphFromEntries(context.Background(), []annotation.Entry{tc.entry}) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, tc.parts...) + }) + } +} + +// TestGenerator_RenderPublisherDrivers renders the publisher of a consumer +// with drivers=[nats, pubsub] (#207): no Kafka file, client or import, so +// the app links neither confluent-kafka-go nor librdkafka, and an unknown +// driver lists the generated ones. +func TestGenerator_RenderPublisherDrivers(t *testing.T) { + entries := []annotation.Entry{publisherConsumer("order", inject("index=0, drivers=[nats, pubsub]"))} + files, err := NewGenerator(newTestGraph(t, entries), testOptions("/mod")).Render(context.Background()) + require.NoError(t, err) + + golden(t, "testdata/publisher/drivers/publisher.go.golden", files["client/github.com/xgodev/boost/wrapper/publisher/publisher.go"]) + for name, src := range files { + assert.NotContains(t, name, "kafka", "only the listed drivers are generated") + assert.NotContains(t, string(src), "confluent", "%s imports the kafka driver", name) + } + assert.NotNil(t, files["client/github.com/nats-io/nats.go/default_conn.go"]) + assert.NotNil(t, files["client/cloud.google.com/go/pubsub/v2/default_client.go"]) +} diff --git a/inject/spec.go b/inject/spec.go index e8a76445..0499381f 100644 --- a/inject/spec.go +++ b/inject/spec.go @@ -19,12 +19,15 @@ type AnnotationIDType int // Index points to the function result (@Provide) or parameter (@Inject); // Name and Group map to fx `name:"..."` and `group:"..."` tags. Plugins, on // the @Inject of a client parameter, lists the plugins of the client inject -// builds ("[otel, prometheus]"). +// builds ("[otel, prometheus]"). Drivers, on the @Inject of a +// *publisher.Publisher, lists the publisher drivers inject generates +// ("[nats, pubsub]"); all of them when no consumer lists them. type Annotation struct { Index *int Name string Group string Plugins string + Drivers string } // IDType reports whether the annotation is a default, named or grouped value. diff --git a/inject/testdata/publisher/drivers/publisher.go.golden b/inject/testdata/publisher/drivers/publisher.go.golden new file mode 100644 index 00000000..1ff14238 --- /dev/null +++ b/inject/testdata/publisher/drivers/publisher.go.golden @@ -0,0 +1,66 @@ +// Code generated by inject; DO NOT EDIT. + +package publisher + +import ( + "sync" + + contextfx "github.com/xgodev/boost/fx/modules/core/context" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/config" + wrapper "github.com/xgodev/boost/wrapper/publisher" + "go.uber.org/fx" + + client_pubsub_39dc3220 "example.com/app/gen/client/cloud.google.com/go/pubsub/v2" + client_nats_92a4ce51 "example.com/app/gen/client/github.com/nats-io/nats.go" + driver_pubsub_d9085a5a "github.com/xgodev/boost/wrapper/publisher/driver/contrib/cloud.google.com/pubsub/v2" + driver_nats_3a6a55c7 "github.com/xgodev/boost/wrapper/publisher/driver/contrib/nats-io/nats.go/v1" + driver_noop_f9e2c811 "github.com/xgodev/boost/wrapper/publisher/driver/extra/noop" +) + +// publisherDriverKey chooses the driver of the publisher. +const publisherDriverKey = "boost.wrapper.publisher.driver.name" + +func init() { + config.Add(publisherDriverKey, "noop", "driver of the publisher inject builds: nats, noop, pubsub") +} + +// PublisherDriver returns the name of the driver PublisherModule wires. +func PublisherDriver() string { + return config.String(publisherDriverKey) +} + +var publisherOnce sync.Once + +// PublisherModule provides the *publisher.Publisher of +// github.com/xgodev/boost/wrapper/publisher with the driver named by +// boost.wrapper.publisher.driver.name (nats, noop, pubsub; default noop) +// and only that driver and its client. An unknown name fails the boot. It +// returns an empty option after the first call, so every consumer can include +// it. +func PublisherModule() fx.Option { + options := fx.Options() + + publisherOnce.Do(func() { + switch name := PublisherDriver(); name { + case "nats": + options = publisherModule(client_nats_92a4ce51.DefaultConnModule(), fx.Provide(fx.Private, driver_nats_3a6a55c7.New)) + case "noop": + options = publisherModule(fx.Provide(fx.Private, driver_noop_f9e2c811.New)) + case "pubsub": + options = publisherModule(client_pubsub_39dc3220.DefaultClientModule(), fx.Provide(fx.Private, driver_pubsub_d9085a5a.New)) + default: + options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "nats, noop, pubsub")) + } + }) + + return options +} + +// publisherModule provides the publisher with the options wiring its driver; +// the driver is private to the module. +func publisherModule(driver ...fx.Option) fx.Option { + return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher", + append([]fx.Option{contextfx.Module(), fx.Provide(wrapper.New)}, driver...)..., + ) +} From fc602bf84e7b9d3d02762ad23fa7495a0b6528df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 11:45:25 -0300 Subject: [PATCH 10/11] feat(inject): named publisher and named client for the @Function adapter (#208) --- builder/README.md | 8 + builder/annotations.go | 12 +- .../examples/simple/cmd/worker/main_test.go | 18 ++- .../gen/builder/worker/function_module.go | 8 +- .../go/pubsub/v2/default_client.go | 50 ------ .../go/pubsub/v2/orders_client.go | 50 ++++++ .../nats-io/nats.go/default_conn.go | 50 ------ .../github.com/nats-io/nats.go/orders_conn.go | 50 ++++++ .../publisher/{ => orders}/publisher.go | 20 +-- builder/examples/simple/gen/module.go | 4 +- .../simple/gen/ordercreated/new_module.go | 6 +- .../examples/simple/ordercreated/handler.go | 4 +- builder/function_door.go | 14 +- builder/generator_test.go | 54 +++++++ builder/newspec.go | 43 ++++- builder/run.go | 2 +- builder/spec.go | 51 +++++- builder/spec_test.go | 2 + .../named_client_function_module.go.golden | 77 +++++++++ fx/modules/bootstrap/function/README.md | 5 +- .../cloud.google.com/pubsub/v2/module.go | 33 +++- .../cloud.google.com/pubsub/v2/module_test.go | 24 +++ .../v2/contrib/nats-io/nats.go/v1/module.go | 33 +++- .../contrib/nats-io/nats.go/v1/module_test.go | 24 +++ .../contrib/nats-io/nats.go/v1/module.go | 32 +++- .../contrib/nats-io/nats.go/v1/module_test.go | 20 +++ inject/README.md | 16 +- inject/client.go | 18 ++- inject/client_test.go | 74 +++++++++ inject/generator.go | 54 +++++-- inject/graph.go | 6 +- inject/publisher.go | 152 ++++++++++++++---- inject/publisher_test.go | 73 ++++++++- inject/template.go | 6 + inject/templates/module.tmpl | 16 +- .../publisher/named/publisher.go.golden | 68 ++++++++ .../publisher/named/publisher_kafka.go.golden | 18 +++ .../named/publisher_kafka_nocgo.go.golden | 16 ++ 38 files changed, 980 insertions(+), 231 deletions(-) delete mode 100644 builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/default_client.go create mode 100644 builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/orders_client.go delete mode 100644 builder/examples/simple/gen/client/github.com/nats-io/nats.go/default_conn.go create mode 100644 builder/examples/simple/gen/client/github.com/nats-io/nats.go/orders_conn.go rename builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/{ => orders}/publisher.go (66%) create mode 100644 builder/testdata/golden/adapters/named_client_function_module.go.golden create mode 100644 inject/testdata/publisher/named/publisher.go.golden create mode 100644 inject/testdata/publisher/named/publisher_kafka.go.golden create mode 100644 inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden diff --git a/builder/README.md b/builder/README.md index be27c167..0f4eef51 100644 --- a/builder/README.md +++ b/builder/README.md @@ -137,6 +137,14 @@ func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevent and an `@Inject` without `name` get. The door wires the adapter's `ModuleWithoutClient`, so a function that reads from a broker and publishes to it boots with one client. +- `client=orders` reads with the named client `orders` instead: the app's + `@Provide(name=orders)` of that type, or else the one inject generates + (`OrdersConnModule()`, keys `boost.factory.nats.clients.orders.*`), the + same one `@Inject(name=orders)` and the publisher `orders` get. The door + wires the adapter's `ModuleWithNamedClient("orders")`. The name follows + the client names (`[a-z][a-zA-Z0-9]*`), and the adapters must include one + that reads with a client (`nats`, `cenats`, `pubsub`), else `NotValid`. + The pubsub v1 adapter keeps its own client. - With a list, `adapter=[pubsub, nats]`, config chooses the adapter: `.function.adapter` (env `_FUNCTION_ADAPTER`), default the first name. The door includes only the chosen adapter module, so the others and diff --git a/builder/annotations.go b/builder/annotations.go index 6130a254..7c9bcd88 100644 --- a/builder/annotations.go +++ b/builder/annotations.go @@ -48,12 +48,14 @@ type mcpArgs struct { App string `attr:"app"` } -// functionArgs are the arguments of @Function(adapter=nats, app=api, middlewares=[recovery, logger]); -// adapter may be a list, adapter=[pubsub, nats], chosen by config. +// functionArgs are the arguments of @Function(adapter=nats, app=api, middlewares=[recovery, logger], client=prices); +// adapter may be a list, adapter=[pubsub, nats], chosen by config, and client +// names the client the adapters read with, the default one when unset. type functionArgs struct { Adapter string `attr:"adapter"` App string `attr:"app"` Middlewares string `attr:"middlewares"` + Client string `attr:"client"` } // functionMiddleware is a middleware of @Function: the fx module that @@ -128,7 +130,11 @@ var ( restKeys = []string{"method", "path", "server", "app"} grpcKeys = []string{"register", "server", "app"} mcpKeys = []string{"name", "server", "app"} - functionKeys = []string{"adapter", "app", "middlewares"} + functionKeys = []string{"adapter", "app", "middlewares", "client"} + + // clientName is the form of the client of @Function: inject's client + // names, reached from an environment variable. + clientName = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`) // identName is the form of app and server names: they become a package, // a file and a function name and a config path element. diff --git a/builder/examples/simple/cmd/worker/main_test.go b/builder/examples/simple/cmd/worker/main_test.go index b5912fc9..21752910 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -89,12 +89,16 @@ const ( // (worker.function.adapter, generated for adapter=[cloudevents, nats, pubsub]). adapterEnv = "WORKER_FUNCTION_ADAPTER" // driverEnv chooses the driver of the publisher the handler announces - // with (boost.wrapper.publisher.driver.name). - driverEnv = "BOOST_WRAPPER_PUBLISHER_DRIVER_NAME" - natsURLEnv = "BOOST_FACTORY_NATS_URL" - projectEnv = "BOOST_FACTORY_GCP_PUBSUB_API__OPTIONS_PROJECT__ID" - emulatorEnv = "PUBSUB_EMULATOR_HOST" - childEnv = "BUILDER_EXAMPLE_CHILD" + // with (boost.wrapper.publisher.clients.orders.driver.name): the + // publisher and the function share the orders clients. + driverEnv = "BOOST_WRAPPER_PUBLISHER_CLIENTS_ORDERS_DRIVER_NAME" + natsURLEnv = "BOOST_FACTORY_NATS_CLIENTS_ORDERS_URL" + projectEnv = "BOOST_FACTORY_GCP_PUBSUB_CLIENTS_ORDERS_API__OPTIONS_PROJECT__ID" + // v1ProjectEnv is the project of the client the Pub/Sub v1 adapter + // builds itself: only v2 takes the orders client of the app. + v1ProjectEnv = "BOOST_FACTORY_GCP_PUBSUB_API__OPTIONS_PROJECT__ID" + emulatorEnv = "PUBSUB_EMULATOR_HOST" + childEnv = "BUILDER_EXAMPLE_CHILD" ) // TestWorkerAdapter boots app worker in a new process per adapter and @@ -288,7 +292,7 @@ func startPubsubWorker(t *testing.T, fake *pstest.Server, env ...string) (*exec. out := &syncBuffer{} cmd := exec.Command(os.Args[0], "-test.run=^TestMainChild$") cmd.Env = append(append(os.Environ(), adapterEnv+"=pubsub", driverEnv+"=noop", - emulatorEnv+"="+fake.Addr, projectEnv+"=example", mainEnv+"=1"), env...) + emulatorEnv+"="+fake.Addr, projectEnv+"=example", v1ProjectEnv+"=example", mainEnv+"=1"), env...) cmd.Stdout, cmd.Stderr = out, out require.NoError(t, cmd.Start()) exited := make(chan error, 1) diff --git a/builder/examples/simple/gen/builder/worker/function_module.go b/builder/examples/simple/gen/builder/worker/function_module.go index 560b65cf..9346fce6 100644 --- a/builder/examples/simple/gen/builder/worker/function_module.go +++ b/builder/examples/simple/gen/builder/worker/function_module.go @@ -81,14 +81,14 @@ func functionAdapterModule() fx.Option { return cloudeventsfx.Module[*src_cloudevents_c136a3fd.Event]() case "nats": return fx.Options( - natsfx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), - client_nats_5da165e5.DefaultConnModule(), + natsfx.ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("orders"), + client_nats_5da165e5.OrdersConnModule(), ) case "pubsub": return fx.Options( pubsubv1fx.Module[*src_cloudevents_c136a3fd.Event](), - pubsubv2fx.ModuleWithoutClient[*src_cloudevents_c136a3fd.Event](), - client_pubsub_0cd2c75d.DefaultClientModule(), + pubsubv2fx.ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("orders"), + client_pubsub_0cd2c75d.OrdersClientModule(), ) default: return fx.Error(errors.NotValidf("%s: unknown adapter %q; valid: %s", functionAdapterKey, name, "cloudevents, nats, pubsub")) diff --git a/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/default_client.go b/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/default_client.go deleted file mode 100644 index 08d40476..00000000 --- a/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/default_client.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -package pubsub - -import ( - "context" - "sync" - - "go.uber.org/fx" - - client "cloud.google.com/go/pubsub/v2" - factory "github.com/xgodev/boost/factory/contrib/cloud.google.com/pubsub/v2" - contextfx "github.com/xgodev/boost/fx/modules/core/context" -) - -// defaultClientRoot is the config root of the default client (*cloud.google.com/go/pubsub/v2.Client); -// env BOOST_FACTORY_GCP_PUBSUB_*. -const defaultClientRoot = "boost.factory.gcp.pubsub" - -func init() { - factory.ConfigAdd(defaultClientRoot) -} - -var defaultClientOnce sync.Once - -// DefaultClientModule provides the default client (*cloud.google.com/go/pubsub/v2.Client), built from the keys under -// boost.factory.gcp.pubsub without plugins, and closes it when the app stops. -// It returns an empty option after the first call, so every consumer can -// include it. -func DefaultClientModule() fx.Option { - options := fx.Options() - - defaultClientOnce.Do(func() { - options = fx.Module("cloud.google.com/go/pubsub/v2.DefaultClient", - contextfx.Module(), - fx.Provide(newDefaultClient), - ) - }) - - return options -} - -func newDefaultClient(ctx context.Context, lc fx.Lifecycle) (*client.Client, error) { - c, err := factory.NewClientWithConfigPath(ctx, defaultClientRoot) - if err != nil { - return nil, err - } - lc.Append(fx.StopHook(c.Close)) - return c, nil -} diff --git a/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/orders_client.go b/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/orders_client.go new file mode 100644 index 00000000..660a6d43 --- /dev/null +++ b/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2/orders_client.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package pubsub + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "cloud.google.com/go/pubsub/v2" + factory "github.com/xgodev/boost/factory/contrib/cloud.google.com/pubsub/v2" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// ordersClientRoot is the config root of the orders client (*cloud.google.com/go/pubsub/v2.Client); +// env BOOST_FACTORY_GCP_PUBSUB_CLIENTS_ORDERS_*. +const ordersClientRoot = "boost.factory.gcp.pubsub.clients.orders" + +func init() { + factory.ConfigAdd(ordersClientRoot) +} + +var ordersClientOnce sync.Once + +// OrdersClientModule provides the orders client (*cloud.google.com/go/pubsub/v2.Client), built from the keys under +// boost.factory.gcp.pubsub.clients.orders without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func OrdersClientModule() fx.Option { + options := fx.Options() + + ordersClientOnce.Do(func() { + options = fx.Module("cloud.google.com/go/pubsub/v2.OrdersClient", + contextfx.Module(), + fx.Provide(fx.Annotate(newOrdersClient, fx.ResultTags(`name:"orders"`))), + ) + }) + + return options +} + +func newOrdersClient(ctx context.Context, lc fx.Lifecycle) (*client.Client, error) { + c, err := factory.NewClientWithConfigPath(ctx, ordersClientRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/builder/examples/simple/gen/client/github.com/nats-io/nats.go/default_conn.go b/builder/examples/simple/gen/client/github.com/nats-io/nats.go/default_conn.go deleted file mode 100644 index 17bd8c80..00000000 --- a/builder/examples/simple/gen/client/github.com/nats-io/nats.go/default_conn.go +++ /dev/null @@ -1,50 +0,0 @@ -// Code generated by inject; DO NOT EDIT. - -package nats - -import ( - "context" - "sync" - - "go.uber.org/fx" - - client "github.com/nats-io/nats.go" - factory "github.com/xgodev/boost/factory/contrib/nats-io/nats.go/v1" - contextfx "github.com/xgodev/boost/fx/modules/core/context" -) - -// defaultConnRoot is the config root of the default client (*github.com/nats-io/nats.go.Conn); -// env BOOST_FACTORY_NATS_*. -const defaultConnRoot = "boost.factory.nats" - -func init() { - factory.ConfigAdd(defaultConnRoot) -} - -var defaultConnOnce sync.Once - -// DefaultConnModule provides the default client (*github.com/nats-io/nats.go.Conn), built from the keys under -// boost.factory.nats without plugins, and closes it when the app stops. -// It returns an empty option after the first call, so every consumer can -// include it. -func DefaultConnModule() fx.Option { - options := fx.Options() - - defaultConnOnce.Do(func() { - options = fx.Module("github.com/nats-io/nats.go.DefaultConn", - contextfx.Module(), - fx.Provide(newDefaultConn), - ) - }) - - return options -} - -func newDefaultConn(ctx context.Context, lc fx.Lifecycle) (*client.Conn, error) { - c, err := factory.NewConnWithConfigPath(ctx, defaultConnRoot) - if err != nil { - return nil, err - } - lc.Append(fx.StopHook(c.Close)) - return c, nil -} diff --git a/builder/examples/simple/gen/client/github.com/nats-io/nats.go/orders_conn.go b/builder/examples/simple/gen/client/github.com/nats-io/nats.go/orders_conn.go new file mode 100644 index 00000000..9d217a71 --- /dev/null +++ b/builder/examples/simple/gen/client/github.com/nats-io/nats.go/orders_conn.go @@ -0,0 +1,50 @@ +// Code generated by inject; DO NOT EDIT. + +package nats + +import ( + "context" + "sync" + + "go.uber.org/fx" + + client "github.com/nats-io/nats.go" + factory "github.com/xgodev/boost/factory/contrib/nats-io/nats.go/v1" + contextfx "github.com/xgodev/boost/fx/modules/core/context" +) + +// ordersConnRoot is the config root of the orders client (*github.com/nats-io/nats.go.Conn); +// env BOOST_FACTORY_NATS_CLIENTS_ORDERS_*. +const ordersConnRoot = "boost.factory.nats.clients.orders" + +func init() { + factory.ConfigAdd(ordersConnRoot) +} + +var ordersConnOnce sync.Once + +// OrdersConnModule provides the orders client (*github.com/nats-io/nats.go.Conn), built from the keys under +// boost.factory.nats.clients.orders without plugins, and closes it when the app stops. +// It returns an empty option after the first call, so every consumer can +// include it. +func OrdersConnModule() fx.Option { + options := fx.Options() + + ordersConnOnce.Do(func() { + options = fx.Module("github.com/nats-io/nats.go.OrdersConn", + contextfx.Module(), + fx.Provide(fx.Annotate(newOrdersConn, fx.ResultTags(`name:"orders"`))), + ) + }) + + return options +} + +func newOrdersConn(ctx context.Context, lc fx.Lifecycle) (*client.Conn, error) { + c, err := factory.NewConnWithConfigPath(ctx, ordersConnRoot) + if err != nil { + return nil, err + } + lc.Append(fx.StopHook(c.Close)) + return c, nil +} diff --git a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders/publisher.go similarity index 66% rename from builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go rename to builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders/publisher.go index 51853dbe..06babf8a 100644 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go +++ b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders/publisher.go @@ -1,6 +1,6 @@ // Code generated by inject; DO NOT EDIT. -package publisher +package orders import ( "sync" @@ -19,10 +19,10 @@ import ( ) // publisherDriverKey chooses the driver of the publisher. -const publisherDriverKey = "boost.wrapper.publisher.driver.name" +const publisherDriverKey = "boost.wrapper.publisher.clients.orders.driver.name" func init() { - config.Add(publisherDriverKey, "noop", "driver of the publisher inject builds: nats, noop, pubsub") + config.Add(publisherDriverKey, "noop", "driver of the orders publisher inject builds: nats, noop, pubsub") } // PublisherDriver returns the name of the driver PublisherModule wires. @@ -33,9 +33,9 @@ func PublisherDriver() string { var publisherOnce sync.Once // PublisherModule provides the *publisher.Publisher of -// github.com/xgodev/boost/wrapper/publisher with the driver named by -// boost.wrapper.publisher.driver.name (nats, noop, pubsub; default noop) -// and only that driver and its client. An unknown name fails the boot. It +// github.com/xgodev/boost/wrapper/publisher named orders with the driver named by +// boost.wrapper.publisher.clients.orders.driver.name (nats, noop, pubsub; default noop) +// and only that driver and its client, the one named orders. An unknown name fails the boot. It // returns an empty option after the first call, so every consumer can include // it. func PublisherModule() fx.Option { @@ -44,11 +44,11 @@ func PublisherModule() fx.Option { publisherOnce.Do(func() { switch name := PublisherDriver(); name { case "nats": - options = publisherModule(client_nats_5da165e5.DefaultConnModule(), fx.Provide(fx.Private, driver_nats_3a6a55c7.New)) + options = publisherModule(client_nats_5da165e5.OrdersConnModule(), fx.Provide(fx.Private, fx.Annotate(driver_nats_3a6a55c7.New, fx.ParamTags(`name:"orders"`)))) case "noop": options = publisherModule(fx.Provide(fx.Private, driver_noop_f9e2c811.New)) case "pubsub": - options = publisherModule(client_pubsub_0cd2c75d.DefaultClientModule(), fx.Provide(fx.Private, driver_pubsub_d9085a5a.New)) + options = publisherModule(client_pubsub_0cd2c75d.OrdersClientModule(), fx.Provide(fx.Private, fx.Annotate(driver_pubsub_d9085a5a.New, fx.ParamTags("", `name:"orders"`)))) default: options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "nats, noop, pubsub")) } @@ -60,7 +60,7 @@ func PublisherModule() fx.Option { // publisherModule provides the publisher with the options wiring its driver; // the driver is private to the module. func publisherModule(driver ...fx.Option) fx.Option { - return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher", - append([]fx.Option{contextfx.Module(), fx.Provide(wrapper.New)}, driver...)..., + return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher_orders", + append([]fx.Option{contextfx.Module(), fx.Provide(fx.Annotate(wrapper.New, fx.ResultTags(`name:"orders"`)))}, driver...)..., ) } diff --git a/builder/examples/simple/gen/module.go b/builder/examples/simple/gen/module.go index 21246f7d..b7e9e88c 100644 --- a/builder/examples/simple/gen/module.go +++ b/builder/examples/simple/gen/module.go @@ -6,7 +6,7 @@ import ( "go.uber.org/fx" client_resty_6382930f "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/go-resty/resty/v2" - client_publisher_ba7c909f "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher" + client_orders_285e8251 "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders" gen_createorder_c746c07a "github.com/xgodev/boost/builder/examples/simple/gen/createorder" gen_healthcheck_d49c24ae "github.com/xgodev/boost/builder/examples/simple/gen/healthcheck" impl_stock_9c4bcef0 "github.com/xgodev/boost/builder/examples/simple/gen/implementation/stock" @@ -31,6 +31,6 @@ func Module() fx.Option { gen_memory_109e67b9.OptionsModule(), gen_stocktool_d9696083.NewModule(), client_resty_6382930f.WarehouseClientModule(), - client_publisher_ba7c909f.PublisherModule(), + client_orders_285e8251.PublisherModule(), ) } diff --git a/builder/examples/simple/gen/ordercreated/new_module.go b/builder/examples/simple/gen/ordercreated/new_module.go index d486c033..ed177e2d 100644 --- a/builder/examples/simple/gen/ordercreated/new_module.go +++ b/builder/examples/simple/gen/ordercreated/new_module.go @@ -7,7 +7,7 @@ import ( "go.uber.org/fx" - client_publisher_ba7c909f "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher" + client_orders_285e8251 "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders" gen_order_f1172a0b "github.com/xgodev/boost/builder/examples/simple/gen/order" src_ordercreated_37848569 "github.com/xgodev/boost/builder/examples/simple/ordercreated" ) @@ -23,8 +23,8 @@ func NewModule() fx.Option { NewOnce.Do(func() { options = fx.Module("github.com/xgodev/boost/builder/examples/simple/ordercreated.New", gen_order_f1172a0b.NewServiceModule(), - client_publisher_ba7c909f.PublisherModule(), - fx.Provide(src_ordercreated_37848569.New), + client_orders_285e8251.PublisherModule(), + fx.Provide(fx.Annotate(src_ordercreated_37848569.New, fx.ParamTags("", `name:"orders"`))), ) }) diff --git a/builder/examples/simple/ordercreated/handler.go b/builder/examples/simple/ordercreated/handler.go index dd44bff3..21cec0c6 100644 --- a/builder/examples/simple/ordercreated/handler.go +++ b/builder/examples/simple/ordercreated/handler.go @@ -26,7 +26,7 @@ type Handler struct { // may read from; only the nats and pubsub drivers (and noop, the default) // are generated, so the worker links no Kafka client nor librdkafka. // @Provide(index=0) -// @Inject(index=1, drivers=[nats, pubsub]) +// @Inject(index=1, name=orders, drivers=[nats, pubsub]) func New(svc *order.Service, pub *publisher.Publisher) *Handler { return &Handler{svc: svc, pub: pub} } @@ -36,7 +36,7 @@ type payload struct { } // Handle records the order of the event, announces it and replies with it. -// @Function(adapter=[cloudevents, nats, pubsub], app=worker, middlewares=[recovery, logger]) +// @Function(adapter=[cloudevents, nats, pubsub], app=worker, client=orders, middlewares=[recovery, logger]) func (h *Handler) Handle(ctx context.Context, in cloudevents.Event) (*cloudevents.Event, error) { var p payload if err := in.DataAs(&p); err != nil || p.Item == "" { diff --git a/builder/function_door.go b/builder/function_door.go index 2f830b62..ba051f4e 100644 --- a/builder/function_door.go +++ b/builder/function_door.go @@ -2,6 +2,7 @@ package builder import ( "fmt" + "strconv" "strings" functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" @@ -78,8 +79,9 @@ func functionDoor(base, app string, fn Function) (doorData, error) { } // adapterOptions returns the fx modules that run the named adapter of fn. -// An adapter reading with a client inject builds takes the app's default -// client: the module of its inject constructor, or the one inject generates. +// An adapter reading with a client inject builds takes the app's client of +// that type, the default one or the one client=x names: the module of its +// inject constructor, or the one inject generates. func adapterOptions(base string, imps *imports, result string, fn Function, name string) ([]string, error) { var options []string for _, adapter := range functionAdapters[name] { @@ -95,12 +97,16 @@ func adapterOptions(base string, imps *imports, result string, fn Function, name imp, call = inject.ModuleOf(base, provider.Entry) } else { var err error - if imp, call, err = inject.ClientModule(base, adapter.Client); err != nil { + if imp, call, err = inject.NamedClientModule(base, adapter.Client, fn.Client); err != nil { return nil, err } } imps.add(imp.Alias, imp.Path) - options = append(options, alias+".ModuleWithoutClient["+result+"]()", call) + module := alias + ".ModuleWithoutClient[" + result + "]()" + if fn.Client != "" { + module = alias + ".ModuleWithNamedClient[" + result + "](" + strconv.Quote(fn.Client) + ")" + } + options = append(options, module, call) } return options, nil } diff --git a/builder/generator_test.go b/builder/generator_test.go index 92b4020b..fe0eafcd 100644 --- a/builder/generator_test.go +++ b/builder/generator_test.go @@ -190,6 +190,60 @@ func TestGenerator_RenderFunctionAdapterClient(t *testing.T) { }) } +// TestGenerator_RenderFunctionNamedClient checks @Function(client=prices): +// the adapters reading with a client inject builds take the client named +// prices, the module inject generates for it (Spec.NamedClients) or the one +// of the inject constructor providing it, and no default client (#208). +func TestGenerator_RenderFunctionNamedClient(t *testing.T) { + newHandler := decl("events", "", "New", nil, []string{"Handler"}, ann("Provide", "index=0")) + handle := decl("events", "Handler", "Handle", eventParams, eventResults, ann("Function", "adapter=[nats, cenats, pubsub], app=worker, client=prices")) + const name = "builder/worker/function_module.go" + + t.Run("inject builds the client", func(t *testing.T) { + spec, err := newTestSpec(t, []annotation.Entry{newHandler, handle}) + require.NoError(t, err) + assert.Empty(t, spec.DefaultClients(), "the adapters read with the prices clients") + assert.Equal(t, []inject.ClientRef{ + {ID: "cloud.google.com/go/pubsub/v2.Client", Name: "prices"}, + {ID: "github.com/nats-io/nats.go.Conn", Name: "prices"}, + }, spec.NamedClients()) + + files, err := NewGenerator(spec, testOptions("/src/app")).Render(context.Background()) + require.NoError(t, err) + src := files[name] + assertImportsUsed(t, name, src) + + golden := filepath.Join("testdata", "golden", "adapters", "named_client_function_module.go.golden") + if *update { + require.NoError(t, os.WriteFile(golden, src, 0o644)) + return + } + want, err := os.ReadFile(golden) + require.NoError(t, err, "run go test ./builder/ -run TestGenerator_RenderFunctionNamedClient -update") + assert.Equal(t, string(want), string(src)) + }) + + t.Run("the app provides the client", func(t *testing.T) { + conn := decl("broker", "", "NewConn", nil, []string{"*nats.Conn"}, ann("Provide", "index=0, name=prices")) + conn.Imports = map[string]string{"nats": "github.com/nats-io/nats.go"} + spec, err := newTestSpec(t, []annotation.Entry{newHandler, handle, conn}) + require.NoError(t, err) + assert.Equal(t, []inject.ClientRef{{ID: "cloud.google.com/go/pubsub/v2.Client", Name: "prices"}}, spec.NamedClients(), + "inject builds no *nats.Conn the app provides") + + files, err := NewGenerator(spec, testOptions("/src/app")).Render(context.Background()) + require.NoError(t, err) + src := string(files[name]) + assertImportsUsed(t, name, files[name]) + + _, call := inject.ModuleOf(testModule+"/gen", conn) + assert.Equal(t, 2, strings.Count(src, call+","), "nats and cenats take the app's prices *nats.Conn:\n%s", src) + assert.Equal(t, 3, strings.Count(src, `ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("prices")`), "nats, cenats and pubsub:\n%s", src) + assert.NotContains(t, src, "PricesConnModule()") + assert.Contains(t, src, "PricesClientModule()") + }) +} + func TestGenerator_RenderErrors(t *testing.T) { handle := func(results ...string) annotation.Entry { return decl("events", "", "Handle", eventParams, results, ann("Function", "adapter=nats")) diff --git a/builder/newspec.go b/builder/newspec.go index 223f0769..6e1a69ca 100644 --- a/builder/newspec.go +++ b/builder/newspec.go @@ -3,6 +3,7 @@ package builder import ( "go/token" "path" + "slices" "sort" "strings" @@ -289,24 +290,56 @@ func (b *specBuilder) addFunction(entry annotation.Entry, ann annotation.Annotat return errors.AlreadyExistsf("@Function on %s: app %s already runs %s and an app runs one @Function; set app= to another app", where, app, declName(a.function.Handler.Entry)) } + if err := validateFunctionClient(args.Client, adapters, where); err != nil { + return err + } a.function = &Function{Adapter: adapters[0], Handler: handler, Middlewares: middlewares, Providers: providers, - ClientProviders: adapterClientProviders(b.graph, adapters)} + ClientProviders: adapterClientProviders(b.graph, adapters, args.Client), Client: args.Client} if len(adapters) > 1 { a.function.Alternatives = adapters[1:] } return nil } -// adapterClientProviders returns the inject constructors of the default -// clients the adapters read with, by client type. -func adapterClientProviders(g *graph.Graph[inject.Component], adapters []string) map[string]inject.Component { +// validateFunctionClient checks client=x of @Function: an adapter reads with +// a client inject builds, and the name reaches its keys from an environment +// variable. +func validateFunctionClient(client string, adapters []string, where string) error { + if client == "" { + return nil + } + if !clientName.MatchString(client) { + return errors.NotValidf("@Function on %s: client name %s must match %s so an environment variable reaches its keys", where, client, clientName) + } + var with []string + for name, list := range functionAdapters { + for _, adapter := range list { + if adapter.Client != "" && !slices.Contains(with, name) { + with = append(with, name) + } + } + } + sort.Strings(with) + for _, name := range adapters { + if slices.Contains(with, name) { + return nil + } + } + return errors.NotValidf("@Function on %s: client=%s, but no adapter of %s reads with a client; the adapters with one are %s", + where, client, strings.Join(adapters, ", "), strings.Join(with, ", ")) +} + +// adapterClientProviders returns the inject constructors of the clients +// named client ("" for the default ones) the adapters read with, by client +// type. +func adapterClientProviders(g *graph.Graph[inject.Component], adapters []string, client string) map[string]inject.Component { providers := map[string]inject.Component{} for _, name := range adapters { for _, adapter := range functionAdapters[name] { if adapter.Client == "" { continue } - if comp, ok := inject.DefaultClientProvider(g, adapter.Client); ok { + if comp, ok := inject.ClientProvider(g, adapter.Client, client); ok { providers[adapter.Client] = comp } } diff --git a/builder/run.go b/builder/run.go index 0b8c1c44..4fd2914f 100644 --- a/builder/run.go +++ b/builder/run.go @@ -59,7 +59,7 @@ func newRun(ctx context.Context, src, out, cmd string) (*run, error) { opts := Options{ GeneratorOptions: inject.GeneratorOptions{ModulePath: modulePath, ModuleDir: moduleDir, OutputDir: r.out, - DefaultClients: spec.DefaultClients()}, + DefaultClients: spec.DefaultClients(), NamedClients: spec.NamedClients()}, CmdDir: r.cmd, } r.modules, r.apps = len(g.Vertices), len(spec.Apps) diff --git a/builder/spec.go b/builder/spec.go index 482e39e8..1b99afaa 100644 --- a/builder/spec.go +++ b/builder/spec.go @@ -95,6 +95,9 @@ type Function struct { // adapters read with, by client type ("."). inject // builds the default client of a type no constructor provides. ClientProviders map[string]inject.Component + // Client is the name of the client the adapters read with, from + // client=x; "" for the default client. + Client string } // Adapters returns the adapter and its alternatives, in order. @@ -103,28 +106,60 @@ func (f Function) Adapters() []string { } // DefaultClients returns the client types (".") the -// adapters of the functions read with that no inject constructor provides, -// sorted: inject generates their default client for the function doors. +// adapters of the functions without client=x read with that no inject +// constructor provides, sorted: inject generates their default client for +// the function doors. func (s Spec) DefaultClients() []string { - seen := map[string]bool{} var ids []string + for _, ref := range s.adapterClients() { + if ref.Name == "" { + ids = append(ids, ref.ID) + } + } + return ids +} + +// NamedClients returns the clients the adapters of the functions with +// client=x read with that no inject constructor provides, sorted by type +// and name: inject generates them for the function doors. +func (s Spec) NamedClients() []inject.ClientRef { + var refs []inject.ClientRef + for _, ref := range s.adapterClients() { + if ref.Name != "" { + refs = append(refs, ref) + } + } + return refs +} + +// adapterClients returns the clients the adapters of the functions read +// with that no inject constructor provides, sorted by type and name. +func (s Spec) adapterClients() []inject.ClientRef { + seen := map[inject.ClientRef]bool{} + var refs []inject.ClientRef for _, app := range s.Apps { if app.Function == nil { continue } for _, name := range app.Function.Adapters() { for _, adapter := range functionAdapters[name] { + ref := inject.ClientRef{ID: adapter.Client, Name: app.Function.Client} _, provided := app.Function.ClientProviders[adapter.Client] - if adapter.Client == "" || provided || seen[adapter.Client] { + if adapter.Client == "" || provided || seen[ref] { continue } - seen[adapter.Client] = true - ids = append(ids, adapter.Client) + seen[ref] = true + refs = append(refs, ref) } } } - sort.Strings(ids) - return ids + sort.Slice(refs, func(i, j int) bool { + if refs[i].ID != refs[j].ID { + return refs[i].ID < refs[j].ID + } + return refs[i].Name < refs[j].Name + }) + return refs } // Handler is the annotated function or method serving a door. diff --git a/builder/spec_test.go b/builder/spec_test.go index 00363370..eb6513b7 100644 --- a/builder/spec_test.go +++ b/builder/spec_test.go @@ -308,6 +308,8 @@ func TestNewSpec_Errors(t *testing.T) { {"function middlewares not a list", []annotation.Entry{function("adapter=nats, middlewares=logger")}, errors.IsNotValid, "[a, b]"}, {"empty function middleware", []annotation.Entry{function("adapter=nats, middlewares=[logger, ]")}, errors.IsNotValid, "empty"}, {"publisher without driver", []annotation.Entry{function("adapter=nats, middlewares=[publisher]")}, errors.IsNotFound, "publisher.Driver"}, + {"client of an adapter without client", []annotation.Entry{function("adapter=[lambda, kafka], client=prices")}, errors.IsNotValid, "client=prices"}, + {"client name an environment variable does not reach", []annotation.Entry{function("adapter=nats, client=order_prices")}, errors.IsNotValid, "order_prices"}, } for _, tc := range testCases { diff --git a/builder/testdata/golden/adapters/named_client_function_module.go.golden b/builder/testdata/golden/adapters/named_client_function_module.go.golden new file mode 100644 index 00000000..d85ee7d5 --- /dev/null +++ b/builder/testdata/golden/adapters/named_client_function_module.go.golden @@ -0,0 +1,77 @@ +// Code generated by builder; DO NOT EDIT. + +package worker + +import ( + "sync" + + "go.uber.org/fx" + + src_events_2fb115b0 "example.com/app/events" + client_pubsub_39dc3220 "example.com/app/gen/client/cloud.google.com/go/pubsub/v2" + client_nats_92a4ce51 "example.com/app/gen/client/github.com/nats-io/nats.go" + gen_events_2fb115b0 "example.com/app/gen/events" + src_cloudevents_c136a3fd "github.com/cloudevents/sdk-go/v2" + bsfunction "github.com/xgodev/boost/bootstrap/function" + functionfx "github.com/xgodev/boost/fx/modules/bootstrap/function" + pubsubv1fx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1" + pubsubv2fx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2" + cenatsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1" + natsfx "github.com/xgodev/boost/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1" + errors "github.com/xgodev/boost/model/errors" + config "github.com/xgodev/boost/wrapper/config" +) + +// functionAdapterKey chooses the adapter of the @Function of app worker. +const functionAdapterKey = "worker.function.adapter" + +func init() { + config.Add(functionAdapterKey, "nats", "adapter of the @Function of app worker: nats, cenats, pubsub") +} + +var functionOnce sync.Once + +// FunctionModule runs the @Function handler with the adapter named by worker.function.adapter (nats, cenats, pubsub; default nats). +// It returns an empty option after the first call. +func FunctionModule() fx.Option { + options := fx.Options() + + functionOnce.Do(func() { + options = fx.Module("worker.function", + gen_events_2fb115b0.NewModule(), + fx.Provide(func(h0 src_events_2fb115b0.Handler) bsfunction.Handler[*src_cloudevents_c136a3fd.Event] { + return h0.Handle + }), + functionfx.Module[*src_cloudevents_c136a3fd.Event](nil), + functionAdapterModule(), + ) + }) + + return options +} + +// functionAdapterModule wires the adapter named by worker.function.adapter +// and only that one: the others, and their clients, are never built. An +// unknown name fails the boot. +func functionAdapterModule() fx.Option { + switch name := config.String(functionAdapterKey); name { + case "nats": + return fx.Options( + natsfx.ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("prices"), + client_nats_92a4ce51.PricesConnModule(), + ) + case "cenats": + return fx.Options( + cenatsfx.ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("prices"), + client_nats_92a4ce51.PricesConnModule(), + ) + case "pubsub": + return fx.Options( + pubsubv1fx.Module[*src_cloudevents_c136a3fd.Event](), + pubsubv2fx.ModuleWithNamedClient[*src_cloudevents_c136a3fd.Event]("prices"), + client_pubsub_39dc3220.PricesClientModule(), + ) + default: + return fx.Error(errors.NotValidf("%s: unknown adapter %q; valid: %s", functionAdapterKey, name, "nats, cenats, pubsub")) + } +} diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index aee3da86..70f7bd27 100644 --- a/fx/modules/bootstrap/function/README.md +++ b/fx/modules/bootstrap/function/README.md @@ -35,12 +35,13 @@ The `nats`, `cloudevents_nats` and Pub/Sub v2 adapter modules |---|---| | `Module[T]()` | brings its own: the factory fx module of `*nats.Conn` or `*pubsub.Client` | | `ModuleWithoutClient[T]()` | takes the one the app provides | +| `ModuleWithNamedClient[T](name)` | takes the one the app provides as `name:""` (nats, cenats, pubsub v2) | Use `ModuleWithoutClient` when the app already provides that client, such as the default client a publisher driver or an `@Inject` gets: fx accepts one provider per type, so `Module` there fails the boot with "already provided". -builder generates it that way. Both share one `sync.Once`: the first call -wins. +builder generates it that way, and `ModuleWithNamedClient` for +`@Function(client=x)`. All share one `sync.Once`: the first call wins. The function runs the middlewares given to `Module`, then the `[]middleware.AnyErrorMiddleware[T]` provided under the name diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module.go b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module.go index 77520c8a..97dc6d6e 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module.go @@ -29,10 +29,36 @@ func ModuleWithoutClient[T any]() fx.Option { return module[T](nil) } +// ModuleWithNamedClient adds the Pub/Sub v2 adapter to the function adapters +// and takes its *pubsub.Client from the app: the one named name, which +// builder wires for @Function(client=name) with the named client inject +// generates. It is empty unless the adapter is enabled and its version is v2. +func ModuleWithNamedClient[T any](name string) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide(fx.Annotate(pubsub.New[T], + fx.ParamTags(`name:"`+name+`"`), + fx.ResultTags(`group:"`+function.BSFunctionAdaptersGroupKey+`"`), + )) + }, nil) +} + // module returns the adapter, with the module of its client unless client is // nil, once. client is called only then, so a disabled adapter leaves the // factory module to the app. func module[T any](client func() fx.Option) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide( + fx.Annotated{ + Group: function.BSFunctionAdaptersGroupKey, + Target: pubsub.New[T], + }, + ) + }, client) +} + +// moduleOf returns the option adapter provides, with the module of its +// client unless client is nil, once. +func moduleOf(adapter, client func() fx.Option) fx.Option { if _, err := pubsubcfg.Version(adaptercfg.VersionKey); err != nil { return fx.Error(err) } @@ -43,12 +69,7 @@ func module[T any](client func() fx.Option) fx.Option { } once.Do(func() { - options = fx.Provide( - fx.Annotated{ - Group: function.BSFunctionAdaptersGroupKey, - Target: pubsub.New[T], - }, - ) + options = adapter() if client != nil { options = fx.Options(client(), options) } diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module_test.go b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module_test.go index dfca70f9..2cb12352 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module_test.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v2/module_test.go @@ -33,3 +33,27 @@ func TestModuleWithoutClientTakesTheAppClient(t *testing.T) { require.Len(t, cmds, 1) assert.True(t, provided, "the adapter reads with the app's *pubsub.Client") } + +// TestModuleWithNamedClientTakesTheNamedClient wires the adapter with the +// *pubsub.Client named prices, the app's only one (#208). +func TestModuleWithNamedClientTakesTheNamedClient(t *testing.T) { + load(t, "true", "v2", "v2") + once = sync.Once{} + t.Cleanup(func() { once = sync.Once{} }) + + provided := false + var cmds []function.CmdFunc[string] + app := fx.New( + fx.NopLogger, + fx.Provide(fx.Annotate(func() *pubsub.Client { provided = true; return &pubsub.Client{} }, fx.ResultTags(`name:"prices"`))), + ModuleWithNamedClient[string]("prices"), + fx.Invoke(fx.Annotate( + func(c []function.CmdFunc[string]) { cmds = c }, + fx.ParamTags(`group:"`+fxfunction.BSFunctionAdaptersGroupKey+`"`), + )), + ) + require.NoError(t, app.Err(), "the adapter reads with the *pubsub.Client named prices") + + require.Len(t, cmds, 1) + assert.True(t, provided) +} diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module.go b/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module.go index 66e3f2b6..88ab4e0e 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module.go @@ -26,22 +26,43 @@ func ModuleWithoutClient[T any]() fx.Option { return module[T](nil) } +// ModuleWithNamedClient adds the CloudEvents over NATS adapter to the function +// adapters and takes its *nats.Conn from the app: the one named name, which +// builder wires for @Function(client=name) with the named client inject +// generates. +func ModuleWithNamedClient[T any](name string) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide(fx.Annotate(nats.New[T], + fx.ParamTags(`name:"`+name+`"`), + fx.ResultTags(`group:"`+function.BSFunctionAdaptersGroupKey+`"`), + )) + }, nil) +} + // module returns the adapter, with the module of its client unless client is // nil, once. client is called only then, so a disabled adapter leaves the // factory module to the app. func module[T any](client func() fx.Option) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide( + fx.Annotated{ + Group: function.BSFunctionAdaptersGroupKey, + Target: nats.New[T], + }, + ) + }, client) +} + +// moduleOf returns the option adapter provides, with the module of its +// client unless client is nil, once. +func moduleOf(adapter, client func() fx.Option) fx.Option { options := fx.Options() if !IsEnabled() { return options } once.Do(func() { - options = fx.Provide( - fx.Annotated{ - Group: function.BSFunctionAdaptersGroupKey, - Target: nats.New[T], - }, - ) + options = adapter() if client != nil { options = fx.Options(client(), options) } diff --git a/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module_test.go b/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module_test.go index 4c21da92..d167b07e 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module_test.go +++ b/fx/modules/bootstrap/function/adapter/contrib/cloudevents/sdk-go/v2/contrib/nats-io/nats.go/v1/module_test.go @@ -71,3 +71,27 @@ func TestModuleWithoutClientTakesTheAppConn(t *testing.T) { assert.Equal(t, "cloudevents_nats", adapters[0](nil).Use) assert.True(t, provided, "the adapter reads with the app's *nats.Conn") } + +// TestModuleWithNamedClientTakesTheNamedConn wires the adapter with the +// *nats.Conn named prices, the app's only one (#208). +func TestModuleWithNamedClientTakesTheNamedConn(t *testing.T) { + once = sync.Once{} + t.Cleanup(func() { once = sync.Once{} }) + + provided := false + var adapters []function.CmdFunc[*event.Event] + app := fx.New( + fx.NopLogger, + fx.Provide(fx.Annotate(func() *nats.Conn { provided = true; return &nats.Conn{} }, fx.ResultTags(`name:"prices"`))), + ModuleWithNamedClient[*event.Event]("prices"), + fx.Invoke(fx.Annotate( + func(a []function.CmdFunc[*event.Event]) { adapters = a }, + fx.ParamTags(`group:"`+fxfunction.BSFunctionAdaptersGroupKey+`"`), + )), + ) + require.NoError(t, app.Err(), "the adapter reads with the *nats.Conn named prices") + + require.Len(t, adapters, 1) + assert.Equal(t, "cloudevents_nats", adapters[0](nil).Use) + assert.True(t, provided) +} diff --git a/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module.go b/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module.go index 14169db7..f1efba86 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module.go +++ b/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module.go @@ -24,22 +24,42 @@ func ModuleWithoutClient[T any]() fx.Option { return module[T](nil) } +// ModuleWithNamedClient adds the nats adapter to the function adapters and +// takes its *nats.Conn from the app: the one named name, which builder wires +// for @Function(client=name) with the named client inject generates. +func ModuleWithNamedClient[T any](name string) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide(fx.Annotate(nats.New[T], + fx.ParamTags(`name:"`+name+`"`), + fx.ResultTags(`group:"`+function.BSFunctionAdaptersGroupKey+`"`), + )) + }, nil) +} + // module returns the adapter, with the module of its client unless client is // nil, once. client is called only then, so a disabled adapter leaves the // factory module to the app. func module[T any](client func() fx.Option) fx.Option { + return moduleOf(func() fx.Option { + return fx.Provide( + fx.Annotated{ + Group: function.BSFunctionAdaptersGroupKey, + Target: nats.New[T], + }, + ) + }, client) +} + +// moduleOf returns the option adapter provides, with the module of its +// client unless client is nil, once. +func moduleOf(adapter, client func() fx.Option) fx.Option { options := fx.Options() if !IsEnabled() { return options } once.Do(func() { - options = fx.Provide( - fx.Annotated{ - Group: function.BSFunctionAdaptersGroupKey, - Target: nats.New[T], - }, - ) + options = adapter() if client != nil { options = fx.Options(client(), options) } diff --git a/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module_test.go b/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module_test.go index 0ca5f4c4..b64cfeec 100644 --- a/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module_test.go +++ b/fx/modules/bootstrap/function/adapter/contrib/nats-io/nats.go/v1/module_test.go @@ -58,3 +58,23 @@ func TestModuleBringsItsConn(t *testing.T) { var got []function.CmdFunc[string] assert.NoError(t, fx.ValidateApp(Module[string](), adapters(&got)), "the nats factory module provides the *nats.Conn") } + +// TestModuleWithNamedClientTakesTheNamedConn wires the adapter with the +// *nats.Conn named prices, the app's only one (#208). +func TestModuleWithNamedClientTakesTheNamedConn(t *testing.T) { + reset(t) + + provided := false + var got []function.CmdFunc[string] + app := fx.New( + fx.NopLogger, + fx.Provide(fx.Annotate(func() *nats.Conn { provided = true; return &nats.Conn{} }, fx.ResultTags(`name:"prices"`))), + ModuleWithNamedClient[string]("prices"), + adapters(&got), + ) + require.NoError(t, app.Err(), "the adapter reads with the *nats.Conn named prices") + + require.Len(t, got, 1) + assert.Equal(t, "nats", got[0](nil).Use) + assert.True(t, provided) +} diff --git a/inject/README.md b/inject/README.md index 3ac8455d..0f3ef9a6 100644 --- a/inject/README.md +++ b/inject/README.md @@ -354,9 +354,17 @@ and its client: only with cgo (`//go:build cgo`). An app built with `CGO_ENABLED=0` still builds; choosing `kafka` there fails the boot with `NotSupported`. Leave `kafka` out of `drivers` to keep cgo and librdkafka out of the app. +- **Named.** `@Inject(index=0, name=orders)` on a `*publisher.Publisher` + builds the publisher `orders`, in `gen/client/.../wrapper/publisher/orders` + (`orders.PublisherModule()`, provided as `name:"orders"`). Its drivers take + the named client `orders` of their factory (`boost.factory.nats.clients.orders.*`, + `boost.factory.gcp.pubsub.clients.orders.*`, ...), and its driver is + chosen by `boost.wrapper.publisher.clients.orders.driver.name`. `drivers` + works per publisher. The name follows the client names: `[a-z][a-zA-Z0-9]*`, + else `NotValid`. - **The app wins.** When a `@Provide` of the app provides - `*publisher.Publisher`, it is used and nothing is generated. A named - publisher (`name=x`) is not built: provide it. `plugins` do not apply. + `*publisher.Publisher` (with the same name, or none), it is used and + nothing is generated for it. `plugins` do not apply. - The hand-written driver fx modules (`fx/modules/wrapper/publisher/...`) and their `.enabled` keys are unchanged; use them or `PublisherModule()`, not both. @@ -434,7 +442,9 @@ Generators built on top of inject (such as [`builder`](../builder)) reuse: | `Alias(prefix, name, path)` | collision-free import alias (`prefix_name_hash`) | | `Clients(g)` / `Client.Module(base)` | factory clients the generator builds, and the import and module call of one | | `GeneratorOptions.DefaultClients` / `ClientModule(base, id)` | client types (`github.com/nats-io/nats.go.Conn`) whose default client is generated for the caller to wire, and the import and module call of one (`client_nats_hash.DefaultConnModule()`) | -| `DefaultClientProvider(g, id)` | component providing the default client of a type, with an unnamed, ungrouped `@Provide` | +| `GeneratorOptions.NamedClients` / `NamedClientModule(base, id, name)` | the same for named clients (`ClientRef{ID, Name}`, `client_nats_hash.OrdersConnModule()`) | +| `DefaultClientProvider(g, id)` / `ClientProvider(g, id, name)` | component providing the default (or the named) client of a type, with an ungrouped `@Provide` | +| `Publishers(g)` | publishers the generator builds: the unnamed one, then the named ones by name | | `ExecuteTemplate(tmpl, name, data)` | executes a template and gofmt's the output | | `WriteGenerated(ctx, dir, files, header)` | writes files and removes stale ones starting with `header` | diff --git a/inject/client.go b/inject/client.go index d969100d..e3d4c215 100644 --- a/inject/client.go +++ b/inject/client.go @@ -335,11 +335,19 @@ func Clients(g *graph.Graph[Component]) ([]Client, error) { // default client of type id ("."), with an unnamed, // ungrouped @Provide. inject builds that client only when none does. func DefaultClientProvider(g *graph.Graph[Component], id string) (Component, bool) { + return ClientProvider(g, id, "") +} + +// ClientProvider returns the annotated function that provides the client of +// type id (".") and name, with an ungrouped @Provide of +// that name; name "" is the default client. inject builds that client only +// when none does. +func ClientProvider(g *graph.Graph[Component], id, name string) (Component, bool) { for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Provides) { ann := comp.Provides[idx] - if ann.Name != "" || ann.Group != "" { + if ann.Name != name || ann.Group != "" { continue } if got, ok := pointerTo(comp.Entry, comp.Result(idx)); ok && got == id { @@ -355,11 +363,17 @@ func DefaultClientProvider(g *graph.Graph[Component], id string) (Component, boo // module that provides it, given the import path of the output dir // (BaseImport). It is NotValid for a type inject does not build. func ClientModule(base, id string) (ImportData, string, error) { + return NamedClientModule(base, id, "") +} + +// NamedClientModule is ClientModule for the client named name; name "" is +// the default client. +func NamedClientModule(base, id, name string) (ImportData, string, error) { f, ok := clientFactories[id] if !ok { return ImportData{}, "", errors.NotValidf("inject builds no client of type *%s; it builds %s", id, strings.Join(supportedClients(), ", ")) } - imp, call := Client{Factory: f}.Module(base) + imp, call := Client{Factory: f, Name: name}.Module(base) return imp, call, nil } diff --git a/inject/client_test.go b/inject/client_test.go index 815e5496..d74488a6 100644 --- a/inject/client_test.go +++ b/inject/client_test.go @@ -454,3 +454,77 @@ func TestClientModule(t *testing.T) { _, _, err = ClientModule("example.com/app/gen", "example.com/broker.Conn") assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) } + +// TestGenerator_RenderNamedClients checks the named clients another +// generator asks for (builder, for @Function(client=prices)): inject +// generates them though no consumer injects them, leaves them out of the +// root module, and the publisher of that name shares them (#208). +func TestGenerator_RenderNamedClients(t *testing.T) { + natsConn := natsPath + ".Conn" + opts := testOptions("/mod") + opts.NamedClients = []ClientRef{{ID: natsConn, Name: "prices"}} + + t.Run("without consumers", func(t *testing.T) { + entries := []annotation.Entry{at("app", "New", nil, []string{"*App"}, provide("index=0"))} + files, err := NewGenerator(newTestGraph(t, entries), opts).Render(context.Background()) + require.NoError(t, err) + + assert.NotNil(t, files["client/github.com/nats-io/nats.go/prices_conn.go"], "files: %v", sortedFileNames(files)) + assert.Nil(t, files["client/github.com/nats-io/nats.go/default_conn.go"]) + assert.NotContains(t, string(files["module.go"]), "PricesConnModule()", "the generator that asked for it wires it") + }) + + t.Run("with the publisher of that name", func(t *testing.T) { + entries := []annotation.Entry{publisherConsumer("order", inject("index=0, name=prices, drivers=[nats]"))} + files, err := NewGenerator(newTestGraph(t, entries), opts).Render(context.Background()) + require.NoError(t, err, "a client asked twice is generated once") + + _, conn, err := NamedClientModule("example.com/app/gen", natsConn, "prices") + require.NoError(t, err) + assert.Contains(t, string(files["client/github.com/xgodev/boost/wrapper/publisher/prices/publisher.go"]), conn, + "the nats driver shares the prices *nats.Conn") + }) + + t.Run("a client inject does not build", func(t *testing.T) { + opts := testOptions("/mod") + opts.NamedClients = []ClientRef{{ID: "example.com/broker.Conn", Name: "prices"}} + _, err := NewGenerator(newTestGraph(t, nil), opts).Render(context.Background()) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, "*example.com/broker.Conn") + }) + + t.Run("a name an environment variable does not reach", func(t *testing.T) { + opts := testOptions("/mod") + opts.NamedClients = []ClientRef{{ID: natsConn, Name: "order_prices"}} + _, err := NewGenerator(newTestGraph(t, nil), opts).Render(context.Background()) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, "order_prices") + }) +} + +func TestClientProvider(t *testing.T) { + conn := func(anns ...annotation.Annotation) annotation.Entry { + entry := at("broker", "NewConn", nil, []string{"*nats.Conn"}, anns...) + entry.Imports = map[string]string{"nats": natsPath} + return entry + } + + g := newTestGraph(t, []annotation.Entry{conn(provide("index=0, name=prices"))}) + comp, ok := ClientProvider(g, natsPath+".Conn", "prices") + require.True(t, ok) + assert.Equal(t, "NewConn", comp.Entry.Func.Name) + _, ok = ClientProvider(g, natsPath+".Conn", "") + assert.False(t, ok, "the prices client is not the default one") + _, ok = ClientProvider(g, natsPath+".Conn", "stock") + assert.False(t, ok) +} + +func TestNamedClientModule(t *testing.T) { + imp, call, err := NamedClientModule("example.com/app/gen", natsPath+".Conn", "prices") + require.NoError(t, err) + assert.Equal(t, "example.com/app/gen/client/github.com/nats-io/nats.go", imp.Path) + assert.Equal(t, imp.Alias+".PricesConnModule()", call) + + _, _, err = NamedClientModule("example.com/app/gen", "example.com/broker.Conn", "prices") + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) +} diff --git a/inject/generator.go b/inject/generator.go index 3762313f..55603f9e 100644 --- a/inject/generator.go +++ b/inject/generator.go @@ -43,6 +43,16 @@ type GeneratorOptions struct { // wires it, as builder does for the adapter of a @Function. The root // module leaves them out. Leave out a type the application provides. DefaultClients []string + // NamedClients are the named clients generated even when no @Inject + // parameter asks for them, like DefaultClients: builder asks for the + // client of @Function(client=x). The root module leaves them out. + NamedClients []ClientRef +} + +// ClientRef is a client by type (".") and name. +type ClientRef struct { + ID string + Name string } // Generator writes one fx module per component of the graph, mirroring the @@ -120,7 +130,7 @@ func (p *Generator) Render(ctx context.Context) (map[string][]byte, error) { return nil, err } consumers := clientsByConsumer(clients) - publisher := PublisherOf(p.graph) + publishers := Publishers(p.graph) files := map[string][]byte{} root := RootData{Package: packageIdent(filepath.Base(p.opts.OutputDir))} @@ -136,9 +146,11 @@ func (p *Generator) Render(ctx context.Context) (map[string][]byte, error) { for _, key := range p.graph.SortedKeys() { vertex := p.graph.Vertices[key] - var injects *Publisher - if slices.Contains(publisher.Consumers, key) { - injects = &publisher + var injects []Publisher + for _, pub := range publishers { + if slices.Contains(pub.Consumers, key) { + injects = append(injects, pub) + } } name, src, err := p.renderVertex(tmpl, base, vertex, switches, consumers[key], injects) if err != nil { @@ -173,16 +185,18 @@ func (p *Generator) Render(ctx context.Context) (map[string][]byte, error) { if err := renderClients(tmpl, extra, files); err != nil { return nil, err } - if len(publisher.Consumers) > 0 { + built := slices.Concat(clients, extra) + for _, pub := range publishers { // the clients of the drivers are wired by the publisher, only the chosen one - missing, err := renderPublisher(tmpl, base, publisher, slices.Concat(clients, extra), files) + missing, err := renderPublisher(tmpl, base, pub, built, files) if err != nil { return nil, err } if err := renderClients(tmpl, missing, files); err != nil { return nil, err } - addRoot(publisher.Module(base)) + built = append(built, missing...) + addRoot(pub.Module(base)) } src, err := ExecuteTemplate(tmpl, "root", root) @@ -197,7 +211,7 @@ func (p *Generator) Render(ctx context.Context) (map[string][]byte, error) { // renderVertex returns the path, relative to OutputDir, and the source of // the module of one component: the config module of a @Config struct, the // fx module of a function otherwise. -func (p *Generator) renderVertex(tmpl *template.Template, base string, vertex *graph.Vertex[Component], switches map[string]Implementation, clients []Client, publisher *Publisher) (string, []byte, error) { +func (p *Generator) renderVertex(tmpl *template.Template, base string, vertex *graph.Vertex[Component], switches map[string]Implementation, clients []Client, publishers []Publisher) (string, []byte, error) { comp := vertex.Value name := path.Join(GenPath(base, comp.Entry.Path), strings.ToLower(declName(comp.Entry))+"_module.go") @@ -205,7 +219,7 @@ func (p *Generator) renderVertex(tmpl *template.Template, base string, vertex *g if comp.IsConfig() { data = configData(p.opts.ModulePath, comp) } else { - module, err := p.moduleData(base, vertex, switches, clients, publisher) + module, err := p.moduleData(base, vertex, switches, clients, publishers) if err != nil { return "", nil, err } @@ -237,8 +251,8 @@ func (p *Generator) renderImplementations(tmpl *template.Template, base string, return nil } -// defaultClients returns the default clients of DefaultClients that clients -// lacks, in the order of DefaultClients. It is NotValid for a type inject +// defaultClients returns the clients of DefaultClients and NamedClients that +// clients lacks, in their order. It is NotValid for a type inject // does not build. func (p *Generator) defaultClients(clients []Client) ([]Client, error) { var extra []Client @@ -251,6 +265,18 @@ func (p *Generator) defaultClients(clients []Client) ([]Client, error) { extra = append(extra, Client{Factory: f}) } } + for _, ref := range p.opts.NamedClients { + f, ok := clientFactories[ref.ID] + if !ok { + return nil, errors.NotValidf("inject builds no client of type *%s; it builds %s", ref.ID, strings.Join(supportedClients(), ", ")) + } + if !envSegment.MatchString(ref.Name) { + return nil, errors.NotValidf("client name %s of *%s must match %s so an environment variable reaches its keys", ref.Name, ref.ID, envSegment) + } + if _, ok := clientOf(slices.Concat(clients, extra), ref.ID, ref.Name); !ok { + extra = append(extra, Client{Factory: f, Name: ref.Name}) + } + } return extra, nil } @@ -325,7 +351,7 @@ func implementationData(base, key string, set Implementation) ImplementationData // dependency that is a member of an implementation set is replaced by the // set's switch module; the clients the component injects, then the publisher // when it injects the one inject builds, come after its dependencies. -func (p *Generator) moduleData(base string, vertex *graph.Vertex[Component], switches map[string]Implementation, clients []Client, publisher *Publisher) (ModuleData, error) { +func (p *Generator) moduleData(base string, vertex *graph.Vertex[Component], switches map[string]Implementation, clients []Client, publishers []Publisher) (ModuleData, error) { comp := vertex.Value entry := comp.Entry @@ -384,8 +410,8 @@ func (p *Generator) moduleData(base string, vertex *graph.Vertex[Component], swi data.Deps = append(data.Deps, call) importOnce(imp) } - if publisher != nil { - imp, call := publisher.Module(base) + for _, pub := range publishers { + imp, call := pub.Module(base) data.Deps = append(data.Deps, call) importOnce(imp) } diff --git a/inject/graph.go b/inject/graph.go index 44e8d0ce..cdfd10d6 100644 --- a/inject/graph.go +++ b/inject/graph.go @@ -144,7 +144,11 @@ func linkComponents(ctx context.Context, g *graph.Graph[Component], providers ma log.FromContext(ctx).Debugf("parameter %d of %s (%s) is a client inject builds", i, key, param.Type) continue } - if len(keys) == 0 && injected && ann.Name == "" && ann.Group == "" && isPublisher(comp.Entry, param.Type) { + if len(keys) == 0 && injected && ann.Group == "" && isPublisher(comp.Entry, param.Type) { + if ann.Name != "" && !envSegment.MatchString(ann.Name) { + return errors.NotValidf("@Inject on %s parameter %d: the name %s of the %s inject builds must match %s so an environment variable reaches its keys", + key, i, ann.Name, param.Type, envSegment) + } log.FromContext(ctx).Debugf("parameter %d of %s (%s) is the publisher inject builds", i, key, param.Type) continue } diff --git a/inject/publisher.go b/inject/publisher.go index 0fff9a84..a8fd5680 100644 --- a/inject/publisher.go +++ b/inject/publisher.go @@ -1,8 +1,10 @@ package inject import ( + "fmt" "path" "slices" + "sort" "strings" "text/template" @@ -20,6 +22,9 @@ const ( PublisherDriverKey = "boost.wrapper.publisher.driver.name" // DefaultPublisherDriver is the driver when PublisherDriverKey is unset. DefaultPublisherDriver = "noop" + // publisherRoot is the config root of the publishers inject builds; a + // named publisher keeps its driver key under publisherRoot.clients.. + publisherRoot = "boost.wrapper.publisher" driverContrib = "github.com/xgodev/boost/wrapper/publisher/driver/contrib/" ) @@ -31,18 +36,20 @@ type PublisherDriver struct { Path string // import path of the driver package, whose New builds it Package string // package name of the driver Client string // "." of the client New takes, "" when none + // ClientParam is the index of the client among the parameters of New. + ClientParam int } // publisherDrivers are the drivers PublisherDriverKey names, sorted by name. // Each client is one inject builds (clientFactories). var publisherDrivers = []PublisherDriver{ {Name: "kafka", Path: driverContrib + "confluentinc/confluent-kafka-go/v2", Package: "confluent", - Client: "github.com/confluentinc/confluent-kafka-go/v2/kafka.Producer"}, + Client: "github.com/confluentinc/confluent-kafka-go/v2/kafka.Producer", ClientParam: 1}, {Name: "nats", Path: driverContrib + "nats-io/nats.go/v1", Package: "nats", Client: "github.com/nats-io/nats.go.Conn"}, {Name: "noop", Path: "github.com/xgodev/boost/wrapper/publisher/driver/extra/noop", Package: "noop"}, {Name: "pubsub", Path: driverContrib + "cloud.google.com/pubsub/v2", Package: "pubsub", - Client: "cloud.google.com/go/pubsub/v2.Client"}, + Client: "cloud.google.com/go/pubsub/v2.Client", ClientParam: 1}, } // publisherDriverNames returns the driver names, comma separated. @@ -77,9 +84,11 @@ func parseDrivers(raw string) ([]string, error) { return names, nil } -// Publisher is the *publisher.Publisher inject builds: one for the -// application, with the driver PublisherDriverKey names. +// Publisher is a *publisher.Publisher inject builds: the unnamed one, with +// the driver PublisherDriverKey names and the default clients, or a named +// one, with the driver its Key names and the clients of its name. type Publisher struct { + Name string // fx name of the publisher, "" for the unnamed one Consumers []string // keys of the components injecting it, sorted // Drivers are the names of the drivers generated, in the order of // publisherDrivers: the ones the consumers list, plus DefaultPublisherDriver; @@ -87,47 +96,75 @@ type Publisher struct { Drivers []string } -// PublisherOf returns the publisher inject builds: its consumers are the -// components with an unnamed, ungrouped @Inject of *publisher.Publisher that -// no annotated function provides. It has no consumers when inject builds none. +// PublisherOf returns the unnamed publisher inject builds: its consumers are +// the components with an unnamed, ungrouped @Inject of *publisher.Publisher +// that no annotated function provides. It has no consumers when inject builds +// none. func PublisherOf(g *graph.Graph[Component]) Publisher { + for _, p := range Publishers(g) { + if p.Name == "" { + return p + } + } + return Publisher{} +} + +// Publishers returns the publishers inject builds, the unnamed one first and +// the named ones by name: one per name of the ungrouped @Inject of +// *publisher.Publisher that no annotated function provides. Each has the +// drivers its consumers list. +func Publishers(g *graph.Graph[Component]) []Publisher { provided := providedValues(g) - var p Publisher - listed := map[string]bool{DefaultPublisherDriver: true} - all := false + byName := map[string]*Publisher{} + listed := map[string]map[string]bool{} + all := map[string]bool{} for _, key := range g.SortedKeys() { comp := g.Vertices[key].Value for _, idx := range sortedIndexes(comp.Injects) { ann := comp.Injects[idx] tp := comp.Entry.Func.Parameters[idx].Type - if ann.Name != "" || ann.Group != "" || !isPublisher(comp.Entry, tp) { + if ann.Group != "" || !isPublisher(comp.Entry, tp) { continue } if _, ok := provided[injectID(comp.Entry, tp, ann)]; ok { continue } + p, ok := byName[ann.Name] + if !ok { + p = &Publisher{Name: ann.Name} + byName[ann.Name] = p + listed[ann.Name] = map[string]bool{DefaultPublisherDriver: true} + } if !slices.Contains(p.Consumers, key) { p.Consumers = append(p.Consumers, key) } // validateDrivers checked the names when the graph was built drivers, _ := parseDrivers(ann.Drivers) - all = all || ann.Drivers == "" + all[ann.Name] = all[ann.Name] || ann.Drivers == "" for _, name := range drivers { - listed[name] = true + listed[ann.Name][name] = true } } } - if len(p.Consumers) == 0 { - return p + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, name) } - for _, d := range publisherDrivers { - if all || listed[d.Name] { - p.Drivers = append(p.Drivers, d.Name) + sort.Strings(names) + + list := make([]Publisher, 0, len(names)) + for _, name := range names { + p := byName[name] + for _, d := range publisherDrivers { + if all[name] || listed[name][d.Name] { + p.Drivers = append(p.Drivers, d.Name) + } } + list = append(list, *p) } - return p + return list } // isPublisher tells whether tp, written in the file of entry, is @@ -137,16 +174,43 @@ func isPublisher(entry annotation.Entry, tp string) bool { return ok && id == publisherWrapper+".Publisher" } -// File returns the path, relative to OutputDir, of the publisher's file. +// File returns the path, relative to OutputDir, of the publisher's file. A +// named publisher has a package of its own, under the unnamed one's. func (p Publisher) File() string { - return path.Join("client", publisherWrapper, "publisher.go") + return path.Join("client", publisherWrapper, p.Name, "publisher.go") +} + +// Key returns the config key choosing the driver of the publisher: +// PublisherDriverKey for the unnamed one, +// boost.wrapper.publisher.clients..driver.name for a named one. +func (p Publisher) Key() string { + if p.Name == "" { + return PublisherDriverKey + } + return publisherRoot + ".clients." + p.Name + ".driver.name" +} + +// String describes the publisher in messages and generated docs. +func (p Publisher) String() string { + if p.Name == "" { + return "the publisher" + } + return "the " + p.Name + " publisher" +} + +// pkg returns the name of the package of the publisher's file. +func (p Publisher) pkg() string { + if p.Name == "" { + return "publisher" + } + return packageIdent(p.Name) } // Import returns the import of the generated package holding the publisher, // given the import path of the output dir (BaseImport). func (p Publisher) Import(base string) ImportData { - pth := path.Join(base, "client", publisherWrapper) - return ImportData{Alias: Alias("client", "publisher", pth), Path: pth} + pth := path.Join(base, "client", publisherWrapper, p.Name) + return ImportData{Alias: Alias("client", p.pkg(), pth), Path: pth} } // Module returns the import of the publisher's package and the call of the @@ -164,13 +228,19 @@ func (p Publisher) constrainedFile(d PublisherDriver, constraint string) (string } // renderPublisher adds the files of the publisher to files and returns the -// default clients of its drivers that clients lacks: the generated package -// of a driver's client holds the module the publisher wires. Only the drivers +// clients of its drivers that clients lacks, the default ones or those of +// the publisher's name: the generated package of a driver's client holds the +// module the publisher wires. Only the drivers // of p are generated, so an app links no other driver nor its client. A // driver whose client builds only under a constraint goes in a file of its // own, with a fallback that fails the boot when the driver is chosen. func renderPublisher(tmpl *template.Template, base string, p Publisher, clients []Client, files map[string][]byte) ([]Client, error) { - data := PublisherData{Key: PublisherDriverKey, Default: DefaultPublisherDriver, Names: strings.Join(p.Drivers, ", ")} + data := PublisherData{Key: p.Key(), Default: DefaultPublisherDriver, Names: strings.Join(p.Drivers, ", "), + Package: p.pkg(), Name: p.Name, Desc: p.String(), Provide: "wrapper.New", Module: publisherWrapper + ".Publisher"} + if p.Name != "" { + data.Provide = "fx.Annotate(wrapper.New, fx.ResultTags(" + goString(fmt.Sprintf("name:%q", p.Name)) + "))" + data.Module += "_" + p.Name + } var missing []Client for _, name := range p.Drivers { d, _ := publisherDriver(name) @@ -180,9 +250,9 @@ func renderPublisher(tmpl *template.Template, base string, p Publisher, clients constraint := "" if d.Client != "" { - c, ok := defaultClient(clients, d.Client) + c, ok := clientOf(clients, d.Client, p.Name) if !ok { - c = Client{Factory: clientFactories[d.Client]} + c = Client{Factory: clientFactories[d.Client], Name: p.Name} missing = append(missing, c) } imp, call := c.Module(base) @@ -190,7 +260,7 @@ func renderPublisher(tmpl *template.Template, base string, p Publisher, clients options = append(options, call) constraint = c.Factory.Constraint } - options = append(options, "fx.Provide(fx.Private, "+drv.Alias+".New)") + options = append(options, "fx.Provide(fx.Private, "+driverNew(drv.Alias, d, p.Name)+")") if constraint == "" { data.Imports = append(data.Imports, imports...) @@ -199,7 +269,7 @@ func renderPublisher(tmpl *template.Template, base string, p Publisher, clients } data.Cases = append(data.Cases, PublisherCase{Name: d.Name, Options: d.Name + "Driver()..."}) - driver := PublisherDriverData{Name: d.Name, Constraint: constraint, Options: strings.Join(options, ", "), Imports: imports} + driver := PublisherDriverData{Package: data.Package, Name: d.Name, Constraint: constraint, Options: strings.Join(options, ", "), Imports: imports} file, fallback := p.constrainedFile(d, constraint) for name, tmplName := range map[string]string{file: "publisher_driver", fallback: "publisher_unsupported"} { src, err := ExecuteTemplate(tmpl, tmplName, driver) @@ -218,11 +288,31 @@ func renderPublisher(tmpl *template.Template, base string, p Publisher, clients return missing, nil } +// driverNew returns the constructor of the driver d given to fx.Provide: a +// named publisher's driver takes the client of that name. +func driverNew(alias string, d PublisherDriver, name string) string { + if name == "" || d.Client == "" { + return alias + ".New" + } + tags := make([]string, d.ClientParam+1) + for i := range tags { + tags[i] = `""` + } + tags[d.ClientParam] = goString(fmt.Sprintf("name:%q", name)) + return "fx.Annotate(" + alias + ".New, fx.ParamTags(" + strings.Join(tags, ", ") + "))" +} + // defaultClient returns the default client of type id (".") // among clients. func defaultClient(clients []Client, id string) (Client, bool) { + return clientOf(clients, id, "") +} + +// clientOf returns the client of type id (".") and name +// among clients, the default one for "". +func clientOf(clients []Client, id, name string) (Client, bool) { for _, c := range clients { - if c.Name == "" && c.Factory.Path+"."+c.Factory.Type == id { + if c.Name == name && c.Factory.Path+"."+c.Factory.Type == id { return c, true } } diff --git a/inject/publisher_test.go b/inject/publisher_test.go index cffd75c4..1d567055 100644 --- a/inject/publisher_test.go +++ b/inject/publisher_test.go @@ -91,9 +91,10 @@ func TestPublisherOf_LeftToTheApp(t *testing.T) { } func TestPublisherOf_Rules(t *testing.T) { - t.Run("a named publisher is not built", func(t *testing.T) { - _, err := NewGraphFromEntries(context.Background(), []annotation.Entry{publisherConsumer("order", inject("index=0, name=orders"))}) - assert.True(t, errors.IsNotFound(err), "unexpected error: %v", err) + t.Run("a named publisher's name reaches its keys from an environment variable", func(t *testing.T) { + _, err := NewGraphFromEntries(context.Background(), []annotation.Entry{publisherConsumer("order", inject("index=0, name=order_prices"))}) + assert.True(t, errors.IsNotValid(err), "unexpected error: %v", err) + errorContains(t, err, "order_prices", "*publisher.Publisher") }) t.Run("plugins", func(t *testing.T) { @@ -223,3 +224,69 @@ func TestGenerator_RenderPublisherDrivers(t *testing.T) { assert.NotNil(t, files["client/github.com/nats-io/nats.go/default_conn.go"]) assert.NotNil(t, files["client/cloud.google.com/go/pubsub/v2/default_client.go"]) } + +// TestPublishers_Named builds a named publisher like the unnamed one, with +// the driver's client of that name (#208). +func TestPublishers_Named(t *testing.T) { + g := newTestGraph(t, []annotation.Entry{ + publisherConsumer("order", inject("index=0")), + publisherConsumer("price", inject("index=0, name=prices, drivers=[nats]")), + publisherConsumer("stock", inject("index=0, name=prices, drivers=[pubsub]")), + }) + + ps := Publishers(g) + require.Len(t, ps, 2) + assert.Equal(t, Publisher{Consumers: []string{testModule + "/order.New"}, Drivers: []string{"kafka", "nats", "noop", "pubsub"}}, ps[0]) + assert.Equal(t, Publisher{Name: "prices", Consumers: []string{testModule + "/price.New", testModule + "/stock.New"}, + Drivers: []string{"nats", "noop", "pubsub"}}, ps[1], "a named publisher has drivers of its own") + assert.Equal(t, ps[0], PublisherOf(g), "PublisherOf is the unnamed publisher") + + prices := ps[1] + assert.Equal(t, "client/github.com/xgodev/boost/wrapper/publisher/prices/publisher.go", prices.File()) + assert.Equal(t, "boost.wrapper.publisher.clients.prices.driver.name", prices.Key()) + assert.Equal(t, PublisherDriverKey, ps[0].Key()) + imp, call := prices.Module("example.com/app/gen") + assert.Equal(t, "example.com/app/gen/client/github.com/xgodev/boost/wrapper/publisher/prices", imp.Path) + assert.Equal(t, imp.Alias+".PublisherModule()", call) +} + +func TestPublishers_None(t *testing.T) { + assert.Empty(t, Publishers(newTestGraph(t, []annotation.Entry{publisherConsumer("order")}))) +} + +// TestGenerator_RenderNamedPublisher renders a publisher named prices: its +// drivers take the prices clients (boost.factory.nats.clients.prices, +// boost.factory.gcp.pubsub.clients.prices) and it provides the publisher +// named prices, the one its consumers inject. +func TestGenerator_RenderNamedPublisher(t *testing.T) { + stream := at("stream", "New", []string{"*nats.Conn"}, []string{"*Stream"}, provide("index=0"), inject("index=0, name=prices")) + stream.Imports = map[string]string{"nats": natsPath} + entries := []annotation.Entry{publisherConsumer("order", inject("index=0, name=prices")), stream} + files, err := NewGenerator(newTestGraph(t, entries), testOptions("/mod")).Render(context.Background()) + require.NoError(t, err) + + dir := "client/github.com/xgodev/boost/wrapper/publisher/prices/" + for _, name := range []string{"publisher.go", "publisher_kafka.go", "publisher_kafka_nocgo.go"} { + require.NotNil(t, files[dir+name], "%s is rendered", name) + golden(t, "testdata/publisher/named/"+name+".golden", files[dir+name]) + } + assert.Nil(t, files["client/github.com/xgodev/boost/wrapper/publisher/publisher.go"], "no consumer injects the unnamed publisher") + for _, client := range []string{ + "client/github.com/nats-io/nats.go/prices_conn.go", + "client/cloud.google.com/go/pubsub/v2/prices_client.go", + "client/github.com/confluentinc/confluent-kafka-go/v2/kafka/prices_producer.go", + } { + assert.NotNil(t, files[client], "%s is the client of a driver of the prices publisher", client) + } + for name := range files { + assert.NotContains(t, name, "default_", "the prices publisher takes no default client") + } + + order := string(files["order/new_module.go"]) + assert.Contains(t, order, "fx.ParamTags(`name:\"prices\"`)", "the consumer injects the publisher named prices") + ps := Publishers(newTestGraph(t, entries)) + require.Len(t, ps, 1) + _, call := ps[0].Module("example.com/app/gen") + assert.Contains(t, order, call+",", "the consumer includes the publisher it injects") + assert.Equal(t, 1, strings.Count(string(files["module.go"]), call+","), "the root includes the publisher once") +} diff --git a/inject/template.go b/inject/template.go index 8936696d..adde3a5c 100644 --- a/inject/template.go +++ b/inject/template.go @@ -84,6 +84,11 @@ type ClientData struct { // PublisherData feeds the template of the publisher inject builds. type PublisherData struct { + Package string // package of the publisher's file + Name string // fx name of the publisher, "" for the unnamed one + Desc string // "the publisher", "the prices publisher" + Provide string // constructor of the publisher given to fx.Provide + Module string // name of the fx module Key string // config key naming the driver Default string // driver when the key is unset Names string // driver names, comma separated @@ -100,6 +105,7 @@ type PublisherCase struct { // PublisherDriverData feeds the templates of a driver whose client builds // only under a constraint: its file and the fallback without the constraint. type PublisherDriverData struct { + Package string // package of the publisher's file Name string // driver name Constraint string // build constraint of its client Options string // fx options wiring the driver and its client diff --git a/inject/templates/module.tmpl b/inject/templates/module.tmpl index e4b58840..2a01d4ed 100644 --- a/inject/templates/module.tmpl +++ b/inject/templates/module.tmpl @@ -226,7 +226,7 @@ func new{{.Ident}}(ctx context.Context{{if .Close}}, lc fx.Lifecycle{{end}}) ({{ {{define "publisher" -}} // Code generated by inject; DO NOT EDIT. -package publisher +package {{.Package}} import ( "sync" @@ -245,7 +245,7 @@ import ( const publisherDriverKey = "{{.Key}}" func init() { - config.Add(publisherDriverKey, "{{.Default}}", "driver of the publisher inject builds: {{.Names}}") + config.Add(publisherDriverKey, "{{.Default}}", "driver of {{.Desc}} inject builds: {{.Names}}") } // PublisherDriver returns the name of the driver PublisherModule wires. @@ -256,9 +256,9 @@ func PublisherDriver() string { var publisherOnce sync.Once // PublisherModule provides the *publisher.Publisher of -// github.com/xgodev/boost/wrapper/publisher with the driver named by +// github.com/xgodev/boost/wrapper/publisher{{if .Name}} named {{.Name}}{{end}} with the driver named by // {{.Key}} ({{.Names}}; default {{.Default}}) -// and only that driver and its client. An unknown name fails the boot. It +// and only that driver and its client{{if .Name}}, the one named {{.Name}}{{end}}. An unknown name fails the boot. It // returns an empty option after the first call, so every consumer can include // it. func PublisherModule() fx.Option { @@ -281,8 +281,8 @@ func PublisherModule() fx.Option { // publisherModule provides the publisher with the options wiring its driver; // the driver is private to the module. func publisherModule(driver ...fx.Option) fx.Option { - return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher", - append([]fx.Option{contextfx.Module(), fx.Provide(wrapper.New)}, driver...)..., + return fx.Module("{{.Module}}", + append([]fx.Option{contextfx.Module(), fx.Provide({{.Provide}})}, driver...)..., ) } {{end}} @@ -292,7 +292,7 @@ func publisherModule(driver ...fx.Option) fx.Option { //go:build {{.Constraint}} -package publisher +package {{.Package}} import ( "go.uber.org/fx" @@ -313,7 +313,7 @@ func {{.Name}}Driver() []fx.Option { //go:build !{{.Constraint}} -package publisher +package {{.Package}} import ( "github.com/xgodev/boost/model/errors" diff --git a/inject/testdata/publisher/named/publisher.go.golden b/inject/testdata/publisher/named/publisher.go.golden new file mode 100644 index 00000000..e69049a2 --- /dev/null +++ b/inject/testdata/publisher/named/publisher.go.golden @@ -0,0 +1,68 @@ +// Code generated by inject; DO NOT EDIT. + +package prices + +import ( + "sync" + + contextfx "github.com/xgodev/boost/fx/modules/core/context" + "github.com/xgodev/boost/model/errors" + "github.com/xgodev/boost/wrapper/config" + wrapper "github.com/xgodev/boost/wrapper/publisher" + "go.uber.org/fx" + + client_pubsub_39dc3220 "example.com/app/gen/client/cloud.google.com/go/pubsub/v2" + client_nats_92a4ce51 "example.com/app/gen/client/github.com/nats-io/nats.go" + driver_pubsub_d9085a5a "github.com/xgodev/boost/wrapper/publisher/driver/contrib/cloud.google.com/pubsub/v2" + driver_nats_3a6a55c7 "github.com/xgodev/boost/wrapper/publisher/driver/contrib/nats-io/nats.go/v1" + driver_noop_f9e2c811 "github.com/xgodev/boost/wrapper/publisher/driver/extra/noop" +) + +// publisherDriverKey chooses the driver of the publisher. +const publisherDriverKey = "boost.wrapper.publisher.clients.prices.driver.name" + +func init() { + config.Add(publisherDriverKey, "noop", "driver of the prices publisher inject builds: kafka, nats, noop, pubsub") +} + +// PublisherDriver returns the name of the driver PublisherModule wires. +func PublisherDriver() string { + return config.String(publisherDriverKey) +} + +var publisherOnce sync.Once + +// PublisherModule provides the *publisher.Publisher of +// github.com/xgodev/boost/wrapper/publisher named prices with the driver named by +// boost.wrapper.publisher.clients.prices.driver.name (kafka, nats, noop, pubsub; default noop) +// and only that driver and its client, the one named prices. An unknown name fails the boot. It +// returns an empty option after the first call, so every consumer can include +// it. +func PublisherModule() fx.Option { + options := fx.Options() + + publisherOnce.Do(func() { + switch name := PublisherDriver(); name { + case "kafka": + options = publisherModule(kafkaDriver()...) + case "nats": + options = publisherModule(client_nats_92a4ce51.PricesConnModule(), fx.Provide(fx.Private, fx.Annotate(driver_nats_3a6a55c7.New, fx.ParamTags(`name:"prices"`)))) + case "noop": + options = publisherModule(fx.Provide(fx.Private, driver_noop_f9e2c811.New)) + case "pubsub": + options = publisherModule(client_pubsub_39dc3220.PricesClientModule(), fx.Provide(fx.Private, fx.Annotate(driver_pubsub_d9085a5a.New, fx.ParamTags("", `name:"prices"`)))) + default: + options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "kafka, nats, noop, pubsub")) + } + }) + + return options +} + +// publisherModule provides the publisher with the options wiring its driver; +// the driver is private to the module. +func publisherModule(driver ...fx.Option) fx.Option { + return fx.Module("github.com/xgodev/boost/wrapper/publisher.Publisher_prices", + append([]fx.Option{contextfx.Module(), fx.Provide(fx.Annotate(wrapper.New, fx.ResultTags(`name:"prices"`)))}, driver...)..., + ) +} diff --git a/inject/testdata/publisher/named/publisher_kafka.go.golden b/inject/testdata/publisher/named/publisher_kafka.go.golden new file mode 100644 index 00000000..eaf8d988 --- /dev/null +++ b/inject/testdata/publisher/named/publisher_kafka.go.golden @@ -0,0 +1,18 @@ +// Code generated by inject; DO NOT EDIT. + +//go:build cgo + +package prices + +import ( + "go.uber.org/fx" + + client_kafka_a3a75155 "example.com/app/gen/client/github.com/confluentinc/confluent-kafka-go/v2/kafka" + driver_confluent_f4b0f64e "github.com/xgodev/boost/wrapper/publisher/driver/contrib/confluentinc/confluent-kafka-go/v2" +) + +// kafkaDriver returns the options wiring the kafka driver and its +// client, which builds only with cgo. +func kafkaDriver() []fx.Option { + return []fx.Option{client_kafka_a3a75155.PricesProducerModule(), fx.Provide(fx.Private, fx.Annotate(driver_confluent_f4b0f64e.New, fx.ParamTags("", `name:"prices"`)))} +} diff --git a/inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden b/inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden new file mode 100644 index 00000000..de3d49f1 --- /dev/null +++ b/inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden @@ -0,0 +1,16 @@ +// Code generated by inject; DO NOT EDIT. + +//go:build !cgo + +package prices + +import ( + "github.com/xgodev/boost/model/errors" + "go.uber.org/fx" +) + +// kafkaDriver fails the boot: the client of the kafka driver builds +// only with cgo, and this build has none. +func kafkaDriver() []fx.Option { + return []fx.Option{fx.Error(errors.NotSupportedf("%s: driver %q is not built in: it needs %s", publisherDriverKey, "kafka", "cgo"))} +} From 048000788375a28b24d5ba6edf6e8b9c19c78c23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Faria?= Date: Mon, 19 Oct 2026 12:45:23 -0300 Subject: [PATCH 11/11] release: v1.6.2 --- README.md | 4 ++-- docs/site/index.html | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 98ef5225..e891a7fb 100644 --- a/README.md +++ b/README.md @@ -869,7 +869,7 @@ same or get better, never worse. require ( - github.com/xgodev/boost v1.1.1 -+ github.com/xgodev/boost v1.6.1 ++ github.com/xgodev/boost v1.6.2 ) ``` @@ -880,7 +880,7 @@ ok github.com/acme/shop/... 1 line changed in go.mod, **0 lines changed in your code**. -Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.6.1`** +Releases: 2024-07 → `v1.1.0` → `v1.1.1` → **`v1.6.2`** ([all releases](https://github.com/xgodev/boost/releases)). ## There's a factory for that. diff --git a/docs/site/index.html b/docs/site/index.html index 3670c474..2219cb21 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -36,7 +36,7 @@ Integrations @@ -48,7 +48,7 @@
-
v1.6.1MITgo 1.18+github.com/xgodev/boost
+
v1.6.2MITgo 1.18+github.com/xgodev/boost

Enterprise Go services.
Built fast.

Boot, config, logs, 58 factories, middleware, health and code generation in one framework. You write the business logic.

@@ -954,14 +954,14 @@

A gate that only moves forward

 module github.com/acme/shop
 
  require (
--	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.6.1 )
+-	github.com/xgodev/boost v1.1.1+	github.com/xgodev/boost v1.6.2 )
 
 $ go build ./... && go test ./...
 ok  github.com/acme/shop/...
1 line changed in go.mod0 lines changed in your code
- 2024-07v1.1.0v1.1.1v1.6.1 + 2024-07v1.1.0v1.1.1v1.6.2