diff --git a/README.md b/README.md index 9d8fafc..127c485 100644 --- a/README.md +++ b/README.md @@ -83,10 +83,7 @@ To consume messages, you Pull them, process them, and `Ack` (acknowledge) them. topicID := uuid.MustParse("00000000-0000-0000-0000-000000000000") publisher := pubsub.NewPublisher(topicID, pubsub.WithHTTPRoundTripper(roundTripper)) -messages := [][]byte{ - []byte("Hello, PubSub!"), -} -messageIDs, err := publisher.Publish(ctx, messages) +messageIDs, err := publisher.PublishStrings(ctx, "Hello, PubSub!") // Pull subscriptionID := uuid.MustParse("00000000-0000-0000-0000-000000000000") @@ -94,8 +91,16 @@ subscriber := pubsub.NewSubscriber(topicID, subscriptionID, pubsub.WithHTTPRound pulledMessages, err := subscriber.Pull(ctx, pubsub.WithMaxMessages(10)) +for i := 0; i < len(pulledMessages); i++ { + msg, err := pulledMessages[i].DecodeString() + if err != nil { + log.Fatalf("Error converting message to string: %v", err) + } + log.Printf("Message [%d]: %s:", i, msg) +} + // Acknowledge -ackIDs := pulledMessages.GetAckIDs() +ackIDs := pulledMessages.AckIDs() err = subscriber.Ack(ctx, ackIDs) ``` @@ -107,7 +112,7 @@ The SDK returns specific error types to help you handle different failure scenar ```go // Example of detailed error handling -_, err := publisher.Publish(ctx, messages) +_, err := publisher.PublishStrings(ctx, "Hello, PubSub!") if err != nil { var apiErr *pubsub.APIError if errors.As(err, &apiErr) { diff --git a/example/example_publish.go b/example/example_publish.go index 11fe16e..eed7895 100644 --- a/example/example_publish.go +++ b/example/example_publish.go @@ -29,13 +29,10 @@ func publish() { pubsub.WithHTTPRoundTripper(rt), ) - // Create a message to publish to the topic and encode it to base64 format - message := pubsub.StringsToBase64("Hello PubSub from example", "This is another message") - // Publish the messages to the topic using the publisher client - _, err = publisher.Publish( + _, err = publisher.PublishStrings( context.Background(), - message, + "Hello PubSub from example", "This is another message", ) if err != nil { log.Fatalf("Error publishing message: %v", err) diff --git a/example/example_pull.go b/example/example_pull.go index fbe2d88..79e80e5 100644 --- a/example/example_pull.go +++ b/example/example_pull.go @@ -38,16 +38,23 @@ func pull() { } log.Printf("Successfully pulled message: %v", pulledMessages) + for i := 0; i < len(pulledMessages); i++ { + msg, err := pulledMessages[i].DecodeString() + if err != nil { + log.Fatalf("Error converting message to string: %v", err) + } + log.Printf("Message [%d]: %s:", i, msg) + } // Get your AckIDs and acknowledge them - ackIDs := pulledMessages.GetAckIDs() + ackIDs := pulledMessages.AckIDs() err = subscriber.Ack(context.Background(), ackIDs) if err != nil { log.Fatalf("Error ack ids: %v", err) } // Get your NackIDs and not acknowledge them - nackIDs := pulledMessages.GetAckIDs() + nackIDs := pulledMessages.AckIDs() err = subscriber.Nack(context.Background(), nackIDs) if err != nil { log.Fatalf("Error nack ids: %v", err) diff --git a/go.mod b/go.mod index fc9b8e2..6ec98f7 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackitcloud/pubsub-sdk-go -go 1.25.0 // Make sure this matches the version specified in the mise.toml +go 1.25.11 // Make sure this matches the version specified in the mise.toml require ( github.com/go-logr/logr v1.4.3 diff --git a/mise.toml b/mise.toml index 280e661..ebb4e2d 100644 --- a/mise.toml +++ b/mise.toml @@ -1,5 +1,5 @@ [tools] -go = "1.25.0" # Make sure this matches the go.mod +go = "1.25.11" # Make sure this matches the go.mod "go:gitlab.com/backbone/changelog/cmd/changelog" = "v1.1.0" ginkgo = "2.28.1" "go:github.com/axw/gocov/gocov" = "v1.1.0" diff --git a/pkg/pubsub/helper.go b/pkg/pubsub/helper.go index c33a00e..d6c848f 100644 --- a/pkg/pubsub/helper.go +++ b/pkg/pubsub/helper.go @@ -2,29 +2,25 @@ package pubsub import ( "encoding/base64" - "fmt" ) -func StringsToBase64(strings ...string) [][]byte { - result := make([][]byte, len(strings)) - - for i, string := range strings { - encodedStrings := base64.StdEncoding.EncodeToString([]byte(string)) - result[i] = []byte(encodedStrings) +// bytesToBase64 encodes multiple raw byte slices into base64 byte slice. +func bytesToBase64(messages ...[]byte) [][]byte { + result := make([][]byte, len(messages)) + for i, msg := range messages { + dst := make([]byte, base64.StdEncoding.EncodedLen(len(msg))) + base64.StdEncoding.Encode(dst, msg) + result[i] = dst } return result } -func Base64ToStrings(encodedStrings ...string) ([]string, error) { - result := make([]string, len(encodedStrings)) - - for i, s := range encodedStrings { - decodedBytes, err := base64.StdEncoding.DecodeString(s) - if err != nil { - return nil, fmt.Errorf("failed to decode string at index %d: %w", i, err) - } - result[i] = string(decodedBytes) +// base64Decode safely decodes a single base64-encoded byte slice. +func base64Decode(src []byte) ([]byte, error) { + dst := make([]byte, base64.StdEncoding.DecodedLen(len(src))) + n, err := base64.StdEncoding.Decode(dst, src) + if err != nil { + return nil, err } - - return result, nil + return dst[:n], nil } diff --git a/pkg/pubsub/message.go b/pkg/pubsub/message.go index 1f860c7..ef4edf1 100644 --- a/pkg/pubsub/message.go +++ b/pkg/pubsub/message.go @@ -2,6 +2,7 @@ package pubsub import ( "context" + "fmt" "time" ) @@ -14,8 +15,6 @@ type PullMessage struct { DeliveryAttempts uint64 } -type PullMessages []PullMessage - func (m *PullMessage) Ack(ctx context.Context) error { return m.subscription.Ack(ctx, []string{m.AckID}) } @@ -24,10 +23,41 @@ func (m *PullMessage) Nack(ctx context.Context) error { return m.subscription.Nack(ctx, []string{m.AckID}) } -func (m PullMessages) GetAckIDs() []string { +type PullMessages []PullMessage + +// AckIDs extracts all AckIDs cleanly from a slice of PullMessages. +func (m PullMessages) AckIDs() []string { ids := make([]string, len(m)) for i, msg := range m { ids[i] = msg.AckID } return ids } + +// DecodeString reverses the transparent base64 encoding, returning the cleartext string. +func (m *PullMessage) DecodeString() (string, error) { + decoded, err := base64Decode(m.Data) + if err != nil { + return "", fmt.Errorf("failed to decode message data: %w", err) + } + return string(decoded), nil +} + +// DecodeStrings decodes an entire slice of messages. +// If any single message is corrupt, it returns the error immediately instead of swallowing it. +func (m PullMessages) DecodeStrings() ([]string, error) { + strings := make([]string, len(m)) + for i, msg := range m { + str, err := msg.DecodeString() + if err != nil { + return nil, fmt.Errorf("failed to decode message at index %d: %w", i, err) + } + strings[i] = str + } + return strings, nil +} + +// Bytes exposes the underlying raw base64 data. +func (m *PullMessage) Bytes() []byte { + return m.Data +} diff --git a/pkg/pubsub/publisher.go b/pkg/pubsub/publisher.go index adaf3de..09a259c 100644 --- a/pkg/pubsub/publisher.go +++ b/pkg/pubsub/publisher.go @@ -15,13 +15,15 @@ import ( type Publisher struct { dataplane *api.ClientWithResponses - TopicId uuid.UUID + TopicID uuid.UUID logger logr.Logger - topicUrl url.URL + topicURL url.URL httpClient *http.Client } -func NewPublisher(topicId uuid.UUID, opts ...Option) *Publisher { +// NewPublisher instantiates a new Publisher. It returns an error if the underlying +// API dataplane client fails to initialize. +func NewPublisher(topicID uuid.UUID, opts ...Option) *Publisher { cfg := &clientConfig{ httpClient: http.DefaultClient, host: "pubsub.eu01.onstackit.cloud", @@ -32,26 +34,42 @@ func NewPublisher(topicId uuid.UUID, opts ...Option) *Publisher { opt(cfg) } - topicUrl := url.URL{Scheme: "https", Host: fmt.Sprintf("%s.%s", topicId.String(), cfg.host)} + topicURL := url.URL{Scheme: "https", Host: fmt.Sprintf("%s.%s", topicID.String(), cfg.host)} + + // SAFETY: The error here can never be non nil, as WithHTTPClient always returns a nil error. + dataplane, _ := api.NewClientWithResponses( + topicURL.String(), + api.WithHTTPClient(cfg.httpClient), + ) publisher := &Publisher{ - TopicId: topicId, - topicUrl: topicUrl, + TopicID: topicID, + topicURL: topicURL, httpClient: cfg.httpClient, - logger: cfg.logger.WithValues("topic_id", topicId), + logger: cfg.logger.WithValues("topic_id", topicID), + dataplane: dataplane, } - publisher.dataplane, _ = api.NewClientWithResponses( - publisher.topicUrl.String(), - api.WithHTTPClient(publisher.httpClient), - ) - return publisher } -func (p *Publisher) Publish(ctx context.Context, messages [][]byte) ([]uint64, error) { - messagesToPublish := make([]api.PublishMessage, len(messages)) +// PublishStrings acts as a lightweight adapter converting string slice to byte slices, +// deferring all encoding safely to the core Publish method. +func (p *Publisher) PublishStrings(ctx context.Context, messages ...string) ([]uint64, error) { + byteMessages := make([][]byte, len(messages)) for i, msg := range messages { + byteMessages[i] = []byte(msg) + } + return p.Publish(ctx, byteMessages) +} + +// Publish processes raw bytes, encodes them transparently using bytesToBase64, +// and transmits them out via the API client. +func (p *Publisher) Publish(ctx context.Context, messages [][]byte) ([]uint64, error) { + encodedMessages := bytesToBase64(messages...) + + messagesToPublish := make([]api.PublishMessage, len(encodedMessages)) + for i, msg := range encodedMessages { messagesToPublish[i] = api.PublishMessage{ Data: msg, } @@ -64,10 +82,7 @@ func (p *Publisher) Publish(ctx context.Context, messages [][]byte) ([]uint64, e p.logger.V(4).Info("publishing messages", "count", len(messages)) resp, err := p.dataplane.PublishMessagesWithResponse(ctx, reqBody) if err != nil { - return nil, &NetworkError{ - Msg: "failed to execute publish messages request", - Err: err, - } + return nil, NewNetworkError("failed to execute publish messages request", err) } if resp.StatusCode() != http.StatusOK { @@ -84,16 +99,12 @@ func (p *Publisher) Publish(ctx context.Context, messages [][]byte) ([]uint64, e } // Purge removes all messages currently stored in the topic. -// This is a destructive operation and cannot be undone. func (p *Publisher) Purge(ctx context.Context) error { p.logger.V(4).Info("purging topic") resp, err := p.dataplane.PurgeTopicWithResponse(ctx) if err != nil { - return &NetworkError{ - Msg: "failed to execute purge topic request", - Err: err, - } + return NewNetworkError("failed to execute purge topic request", err) } if resp.StatusCode() != http.StatusNoContent { diff --git a/pkg/pubsub/publisher_test.go b/pkg/pubsub/publisher_test.go index 884512a..52ba966 100644 --- a/pkg/pubsub/publisher_test.go +++ b/pkg/pubsub/publisher_test.go @@ -14,9 +14,8 @@ var _ = Describe("publish a message", func() { Context("publish a message", func() { It("should publish the message and return a message ID", func(ctx context.Context) { publisher := pubsub.NewPublisher(topicId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) - messages := pubsub.StringsToBase64("Hello, Stackit!") - messageIDs, err := publisher.Publish(ctx, messages) + messageIDs, err := publisher.PublishStrings(ctx, "Hello, Stackit!") Expect(err).ToNot(HaveOccurred()) Expect(messageIDs).ToNot(BeNil()) @@ -28,9 +27,8 @@ var _ = Describe("publish a message", func() { It("should successfully remove all messages from the topic", func(ctx context.Context) { publisher := pubsub.NewPublisher(topicId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) subscriber := pubsub.NewSubscriber(topicId, subscriptionId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) - messagesToPublish := pubsub.StringsToBase64("message-to-be-purged-1", "message-to-be-purged-2") - _, err := publisher.Publish(ctx, messagesToPublish) + _, err := publisher.PublishStrings(ctx, "message-to-be-purged-1", "message-to-be-purged-2") Expect(err).ToNot(HaveOccurred()) Eventually(func(g Gomega) { diff --git a/pkg/pubsub/subscriber.go b/pkg/pubsub/subscriber.go index bed8b47..c8433e7 100644 --- a/pkg/pubsub/subscriber.go +++ b/pkg/pubsub/subscriber.go @@ -15,15 +15,17 @@ import ( ) type Subscriber struct { - SubscriptionId uuid.UUID + SubscriptionID uuid.UUID logger logr.Logger dataplane *api.ClientWithResponses - topicUrl url.URL + topicURL url.URL httpClient *http.Client wg sync.WaitGroup } -func NewSubscriber(topicId uuid.UUID, subscriptionId uuid.UUID, opts ...Option) *Subscriber { +// NewSubscriber instantiates a new Subscriber. It returns an error if the underlying +// API dataplane client fails to initialize. +func NewSubscriber(topicID uuid.UUID, subscriptionID uuid.UUID, opts ...Option) *Subscriber { cfg := &clientConfig{ httpClient: http.DefaultClient, host: "pubsub.eu01.onstackit.cloud", @@ -34,20 +36,22 @@ func NewSubscriber(topicId uuid.UUID, subscriptionId uuid.UUID, opts ...Option) opt(cfg) } - topicUrl := url.URL{Scheme: "https", Host: fmt.Sprintf("%s.%s", topicId.String(), cfg.host)} + topicURL := url.URL{Scheme: "https", Host: fmt.Sprintf("%s.%s", topicID.String(), cfg.host)} + + // SAFETY: The error here can never be non nil, as WithHTTPClient always returns a nil error. + dataplane, _ := api.NewClientWithResponses( + topicURL.String(), + api.WithHTTPClient(cfg.httpClient), + ) subscriber := &Subscriber{ - SubscriptionId: subscriptionId, - topicUrl: topicUrl, + SubscriptionID: subscriptionID, + topicURL: topicURL, httpClient: cfg.httpClient, - logger: cfg.logger.WithValues("subscription_id", topicId), + logger: cfg.logger.WithValues("subscription_id", subscriptionID), + dataplane: dataplane, } - subscriber.dataplane, _ = api.NewClientWithResponses( - subscriber.topicUrl.String(), - api.WithHTTPClient(subscriber.httpClient), - ) - return subscriber } @@ -56,25 +60,18 @@ func (s *Subscriber) Ack(ctx context.Context, ids []string) error { AckIds: ids, } - s.logger.V(4).Info("acknowledging messages", - "count", len(ids), - ) + s.logger.V(4).Info("acknowledging messages", "count", len(ids)) - resp, err := s.dataplane.AckMessagesWithResponse(ctx, s.SubscriptionId, reqBody) + resp, err := s.dataplane.AckMessagesWithResponse(ctx, s.SubscriptionID, reqBody) if err != nil { - return NewNetworkError( - "failed to execute ack messages request", - err, - ) + return NewNetworkError("failed to execute ack messages request", err) } if resp.StatusCode() != http.StatusNoContent { return NewAPIError(resp.StatusCode(), resp.Body) } - s.logger.V(4).Info("acknowledged messages", - "count", len(ids), - ) + s.logger.V(4).Info("acknowledged messages", "count", len(ids)) return nil } @@ -83,29 +80,22 @@ func (s *Subscriber) Nack(ctx context.Context, ids []string) error { NackIds: ids, } - s.logger.V(4).Info("nacking messages", - "count", len(ids), - ) + s.logger.V(4).Info("nacking messages", "count", len(ids)) - resp, err := s.dataplane.NackMessagesWithResponse(ctx, s.SubscriptionId, reqBody) + resp, err := s.dataplane.NackMessagesWithResponse(ctx, s.SubscriptionID, reqBody) if err != nil { - return NewNetworkError( - "failed to execute nack messages request", - err, - ) + return NewNetworkError("failed to execute nack messages request", err) } if resp.StatusCode() != http.StatusNoContent { return NewAPIError(resp.StatusCode(), resp.Body) } - s.logger.V(4).Info("nacked messages", - "count", len(ids), - ) + s.logger.V(4).Info("nacked messages", "count", len(ids)) return nil } -func toSdkMessages(m []api.Message, subscription *Subscriber) PullMessages { +func toSDKMessages(m []api.Message, subscription *Subscriber) PullMessages { sdkMessages := make(PullMessages, len(m)) for i, msg := range m { sdkMessages[i] = PullMessage{ @@ -148,7 +138,6 @@ func (s *Subscriber) Pull(ctx context.Context, opts ...PullOption) (PullMessages opt(cfg) } - // 0 and nil both mean disabled; any other value must be in [100, 5000]. var longPullDuration *int32 if cfg.longPullDuration != nil && *cfg.longPullDuration != 0 { ms := *cfg.longPullDuration @@ -165,28 +154,23 @@ func (s *Subscriber) Pull(ctx context.Context, opts ...PullOption) (PullMessages PubSubLongPullDuration: longPullDuration, } - s.logger.V(4).Info("pulling messages", - "max_messages", int(cfg.maxMessages), - ) + s.logger.V(4).Info("pulling messages", "max_messages", int(cfg.maxMessages)) - resp, err := s.dataplane.PullMessagesWithResponse(ctx, s.SubscriptionId, &reqBody) + resp, err := s.dataplane.PullMessagesWithResponse(ctx, s.SubscriptionID, &reqBody) if err != nil { - return nil, &NetworkError{ - Msg: "failed to execute pull messages request", - Err: err, - } + return nil, NewNetworkError("failed to execute pull messages request", err) } if resp.StatusCode() != http.StatusOK { return nil, NewAPIError(resp.StatusCode(), resp.Body) } - messages := toSdkMessages(resp.JSON200.Messages, s) + messages := toSDKMessages(resp.JSON200.Messages, s) s.logger.V(4).Info( "pulled messages", "count", len(resp.JSON200.Messages), - "ack_ids", messages.GetAckIDs(), + "ack_ids", messages.AckIDs(), ) return messages, nil } diff --git a/pkg/pubsub/subscriber_test.go b/pkg/pubsub/subscriber_test.go index 6ac7d23..0d17ff1 100644 --- a/pkg/pubsub/subscriber_test.go +++ b/pkg/pubsub/subscriber_test.go @@ -17,9 +17,8 @@ var _ = Describe("Acknowledge messages", func() { BeforeEach(func(ctx context.Context) { publisher := pubsub.NewPublisher(topicId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) - messagesToPublish := pubsub.StringsToBase64("test1") - _, err := publisher.Publish(ctx, messagesToPublish) + _, err := publisher.PublishStrings(ctx, "test1") Expect(err).ToNot(HaveOccurred()) subscriber := pubsub.NewSubscriber(topicId, subscriptionId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) @@ -27,7 +26,7 @@ var _ = Describe("Acknowledge messages", func() { Expect(err).ToNot(HaveOccurred()) Expect(pulledMessages).ToNot(BeEmpty()) - AckIDs = pulledMessages.GetAckIDs() + AckIDs = pulledMessages.AckIDs() }) Context("acknowledging messages", func() { @@ -97,9 +96,8 @@ var _ = Describe("Pull messages", func() { Context("pulling messages", func() { BeforeEach(func(ctx context.Context) { publisher := pubsub.NewPublisher(topicId, pubsub.WithHTTPRoundTripper(rt), pubsub.WithHost(environment)) - messagesToPublish := pubsub.StringsToBase64("test1", "test2", "test3") - _, err := publisher.Publish(ctx, messagesToPublish) + _, err := publisher.PublishStrings(ctx, "test1", "test2", "test3") Expect(err).ToNot(HaveOccurred()) }) @@ -135,9 +133,7 @@ var _ = Describe("PullJob", func() { Expect(err).ToNot(HaveOccurred()) // publishing test messages - messagesToPublish := pubsub.StringsToBase64("testMessage") - - _, err = publisher.Publish(ctx, messagesToPublish) + _, err = publisher.PublishStrings(ctx, "testMessage") Expect(err).ToNot(HaveOccurred()) }) @@ -155,10 +151,10 @@ var _ = Describe("PullJob", func() { Eventually(jobChan, "5s").Should(Receive(&receivedMessages)) Expect(receivedMessages).To(HaveLen(1)) - decodedStrings, err := pubsub.Base64ToStrings(string(receivedMessages[0].Data)) + decodedString, err := receivedMessages[0].DecodeString() Expect(err).ToNot(HaveOccurred()) - Expect(decodedStrings[0]).To(Equal("testMessage")) - err = subscriber.Ack(ctx, receivedMessages.GetAckIDs()) + Expect(decodedString).To(Equal("testMessage")) + err = subscriber.Ack(ctx, receivedMessages.AckIDs()) Expect(err).ToNot(HaveOccurred()) cancel() @@ -181,10 +177,10 @@ var _ = Describe("PullJob", func() { Eventually(jobChan, "10s").Should(Receive(&receivedMessages)) Expect(receivedMessages).To(HaveLen(1)) - decodedStrings, err := pubsub.Base64ToStrings(string(receivedMessages[0].Data)) + decodedString, err := receivedMessages[0].DecodeString() Expect(err).ToNot(HaveOccurred()) - Expect(decodedStrings[0]).To(Equal("testMessage")) - err = subscriber.Ack(ctx, receivedMessages.GetAckIDs()) + Expect(decodedString).To(Equal("testMessage")) + err = subscriber.Ack(ctx, receivedMessages.AckIDs()) Expect(err).ToNot(HaveOccurred()) cancel() @@ -203,10 +199,10 @@ var _ = Describe("PullJob", func() { callback := func(ctx context.Context, messages pubsub.PullMessages) { defer GinkgoRecover() Expect(messages).To(HaveLen(1)) - decoded, err := pubsub.Base64ToStrings(string(messages[0].Data)) + decoded, err := messages[0].DecodeString() Expect(err).ToNot(HaveOccurred()) - Expect(decoded[0]).To(Equal("testMessage")) - err = subscriber.Ack(ctx, messages.GetAckIDs()) + Expect(decoded).To(Equal("testMessage")) + err = subscriber.Ack(ctx, messages.AckIDs()) Expect(err).ToNot(HaveOccurred()) callbackInvoked.Store(true) cancel()