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/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..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,9 +33,28 @@ 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() { - logger := log.WithTypeOf(*h) + 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 @@ -45,17 +64,16 @@ 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.Errorf("Failed to subscribe to subscription %s: %v", subscription, err) - } else { - logger.Infof("Successfully subscribed to subscription %s", subscription) + 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 complete + // Wait for all subscriptions to stop wg.Wait() + 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 new file mode 100644 index 00000000..d23c8c46 --- /dev/null +++ b/bootstrap/function/adapter/contrib/cloud.google.com/pubsub/v1/run_test.go @@ -0,0 +1,180 @@ +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()) +} + +// 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. +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..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 @@ -72,8 +74,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 +90,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/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/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/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..0f4eef51 100644 --- a/builder/README.md +++ b/builder/README.md @@ -137,12 +137,25 @@ 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 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`. +- 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 @@ -364,9 +377,11 @@ 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`, - default `cloudevents`), behind the `recovery` and `logger` middlewares. +- `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; 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 @@ -374,8 +389,15 @@ 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 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), +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/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 13ae63ed..21752910 100644 --- a/builder/examples/simple/cmd/worker/main_test.go +++ b/builder/examples/simple/cmd/worker/main_test.go @@ -1,18 +1,27 @@ package main import ( + "bytes" "context" + "encoding/json" "os" "os/exec" + "strings" + "sync" + "syscall" "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" + 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" @@ -22,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. @@ -77,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 @@ -120,6 +136,275 @@ func TestWorkerAdapter(t *testing.T) { } } +// 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 +// 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"`) +} + +// 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 +// 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) + t.Cleanup(srv.Shutdown) + + conn, err := natsgo.Connect(srv.ClientURL()) + require.NoError(t, err) + t.Cleanup(conn.Close) + + 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()) + } + } + 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", v1ProjectEnv+"=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 + 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 the TestMain_* tests") + } + os.Args = os.Args[:1] + 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/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/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/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/orders/publisher.go b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders/publisher.go new file mode 100644 index 00000000..06babf8a --- /dev/null +++ b/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/orders/publisher.go @@ -0,0 +1,66 @@ +// Code generated by inject; DO NOT EDIT. + +package orders + +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_0cd2c75d "github.com/xgodev/boost/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2" + client_nats_5da165e5 "github.com/xgodev/boost/builder/examples/simple/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.orders.driver.name" + +func init() { + config.Add(publisherDriverKey, "noop", "driver of the orders 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 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 { + options := fx.Options() + + publisherOnce.Do(func() { + switch name := PublisherDriver(); name { + case "nats": + 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.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")) + } + }) + + 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_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 289b99d7..21cec0c6 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, name=orders, drivers=[nats, pubsub]) func New(svc *order.Service, pub *publisher.Publisher) *Handler { return &Handler{svc: svc, pub: pub} } @@ -35,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 d4a8909e..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 } } @@ -348,7 +381,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/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/docs/development/gitflow.md b/docs/development/gitflow.md index 810a01b5..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 @@ -48,6 +51,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. 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
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) + } +} diff --git a/fx/modules/bootstrap/function/README.md b/fx/modules/bootstrap/function/README.md index 9e475176..70f7bd27 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 @@ -25,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 @@ -53,3 +64,19 @@ 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 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/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/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 diff --git a/inject/README.md b/inject/README.md index 057f16f6..0f3ef9a6 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) | @@ -24,7 +25,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 @@ -337,12 +340,31 @@ 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. +- **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. @@ -377,6 +399,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; @@ -413,17 +437,22 @@ 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()`) | -| `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` | -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 +461,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..e3d4c215 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) @@ -261,7 +268,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) @@ -328,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 { @@ -348,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 } @@ -363,7 +384,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/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/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/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 c1e94503..cdfd10d6 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,14 +137,18 @@ 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 == "" { 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 } @@ -170,13 +175,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 } } @@ -311,6 +335,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 } @@ -342,6 +369,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 } @@ -407,21 +462,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..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. @@ -54,36 +61,110 @@ func publisherDriverNames() string { return strings.Join(names, ", ") } -// Publisher is the *publisher.Publisher inject builds: one for the -// application, with the driver PublisherDriverKey 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 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; + // all of them when a consumer lists none. + 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 + 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.Package, tp, ann)]; ok { + 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[ann.Name] = all[ann.Name] || ann.Drivers == "" + for _, name := range drivers { + listed[ann.Name][name] = true + } + } + } + + names := make([]string, 0, len(byName)) + for name := range byName { + names = append(names, 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 @@ -93,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 @@ -120,23 +228,31 @@ 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. +// 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: publisherDriverNames()} + 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 _, 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 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) @@ -144,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...) @@ -153,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) @@ -172,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 0d5499cb..1d567055 100644 --- a/inject/publisher_test.go +++ b/inject/publisher_test.go @@ -2,6 +2,7 @@ package inject import ( "context" + "fmt" "strings" "testing" @@ -90,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) { @@ -148,3 +150,143 @@ 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"]) +} + +// 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/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/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/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go b/inject/testdata/publisher/drivers/publisher.go.golden similarity index 77% rename from builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go rename to inject/testdata/publisher/drivers/publisher.go.golden index 8558da96..1ff14238 100644 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher.go +++ b/inject/testdata/publisher/drivers/publisher.go.golden @@ -11,8 +11,8 @@ import ( wrapper "github.com/xgodev/boost/wrapper/publisher" "go.uber.org/fx" - client_pubsub_0cd2c75d "github.com/xgodev/boost/builder/examples/simple/gen/client/cloud.google.com/go/pubsub/v2" - client_nats_5da165e5 "github.com/xgodev/boost/builder/examples/simple/gen/client/github.com/nats-io/nats.go" + 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" @@ -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,16 +43,14 @@ 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)) + 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_0cd2c75d.DefaultClientModule(), fx.Provide(fx.Private, driver_pubsub_d9085a5a.New)) + 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, "kafka, nats, noop, pubsub")) + options = fx.Error(errors.NotValidf("%s: unknown driver %q; valid: %s", publisherDriverKey, name, "nats, noop, pubsub")) } }) 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/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go b/inject/testdata/publisher/named/publisher_kafka.go.golden similarity index 55% rename from builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go rename to inject/testdata/publisher/named/publisher_kafka.go.golden index d8a2d836..eaf8d988 100644 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka.go +++ b/inject/testdata/publisher/named/publisher_kafka.go.golden @@ -2,17 +2,17 @@ //go:build cgo -package publisher +package prices 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" + 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_0b369746.DefaultProducerModule(), fx.Provide(fx.Private, driver_confluent_f4b0f64e.New)} + return []fx.Option{client_kafka_a3a75155.PricesProducerModule(), fx.Provide(fx.Private, fx.Annotate(driver_confluent_f4b0f64e.New, fx.ParamTags("", `name:"prices"`)))} } diff --git a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go b/inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden similarity index 95% rename from builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go rename to inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden index c3b2dbfe..de3d49f1 100644 --- a/builder/examples/simple/gen/client/github.com/xgodev/boost/wrapper/publisher/publisher_kafka_nocgo.go +++ b/inject/testdata/publisher/named/publisher_kafka_nocgo.go.golden @@ -2,7 +2,7 @@ //go:build !cgo -package publisher +package prices import ( "github.com/xgodev/boost/model/errors" 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") +} 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()) +}