diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2be85a7..5b32d88 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,12 +13,13 @@ jobs: runs-on: ubuntu-latest services: rabbitmq: - image: rabbitmq + image: rabbitmq:management env: RABBITMQ_DEFAULT_USER: user RABBITMQ_DEFAULT_PASS: password ports: - 5672:5672 + - 15672:15672 steps: - uses: actions/checkout@v4 - uses: actions/setup-go@v5 @@ -27,7 +28,7 @@ jobs: check-latest: true - name: Tests run: | - go test -p 1 -mod=readonly -race -v -tags integration -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./... + go test -p 1 -mod=readonly -race -v --tags=integration -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./... go tool cover -func=coverage.txt - name: Upload coverage to Codecov uses: codecov/codecov-action@v5 diff --git a/.gitignore b/.gitignore index 3fb038e..fc5e496 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.envrc .idea *.iml .testCoverage.txt diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5ae938..b24e99f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,19 @@ repos: - id: go-test-mod - id: go-fumpt - id: golangci-lint-mod + - repo: https://github.com/Lucas-C/pre-commit-hooks + rev: v1.5.4 + hooks: + - id: insert-license + files: \.go$ + args: + - --license-filepath + - LICENSE + - --comment-style + - // + - --use-current-year + - repo: https://github.com/sparetimecoders/pre-commit-check-signed + rev: v0.0.1 + hooks: + - id: check-signed-commit + name: check-signed-commit diff --git a/LICENSE b/LICENSE index 2dbdee3..e95a576 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 sparetimecoders +Copyright (c) 2025 sparetimecoders Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Makefile b/Makefile index 521e36a..2eaed87 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ rabbitmq-server: --env RABBITMQ_DEFAULT_USER=user \ --env RABBITMQ_DEFAULT_PASS=password \ --env RABBITMQ_DEFAULT_VHOST=test \ - --pull always rabbitmq:3-management + --pull always rabbitmq:4-management .PHONY: stop-rabbitmq-server stop-rabbitmq-server: diff --git a/channel.go b/channel.go new file mode 100644 index 0000000..4ff2ccc --- /dev/null +++ b/channel.go @@ -0,0 +1,56 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "io" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// AmqpChannel wraps the amqp.Channel to allow for mocking +type AmqpChannel interface { + io.Closer + QueueBind(queue, key, exchange string, noWait bool, args amqp.Table) error + Consume(queue, consumer string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) + ExchangeDeclare(name, kind string, durable, autoDelete, internal, noWait bool, args amqp.Table) error + PublishWithContext(ctx context.Context, exchange, key string, mandatory, immediate bool, msg amqp.Publishing) error + QueueDeclare(name string, durable, autoDelete, exclusive, noWait bool, args amqp.Table) (amqp.Queue, error) + NotifyPublish(confirm chan amqp.Confirmation) chan amqp.Confirmation + NotifyClose(c chan *amqp.Error) chan *amqp.Error + Confirm(noWait bool) error + // Qos controls how many messages or how many bytes the server will try to keep on + // the network for queueConsumers before receiving delivery acks. The intent of Qos is + // to make sure the network buffers stay full between the server and client. + // If your consumer work time is reasonably consistent and not much greater + // than two times your network round trip time, you will see significant + // throughput improvements starting with a prefetch count of 2 or slightly + // greater as described by benchmarks on RabbitMQ. + // + // http://www.rabbitmq.com/blog/2012/04/25/rabbitmq-performance-measurements-part-2/ + // The default prefetchCount is 20 (and global true) and can be overridden with WithPrefetchLimit + Qos(prefetchCount, prefetchSize int, global bool) error +} + +var _ AmqpChannel = &amqp.Channel{} diff --git a/connection.go b/connection.go index e4dac2f..aff018a 100644 --- a/connection.go +++ b/connection.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -24,83 +24,36 @@ package goamqp import ( "context" - "encoding/json" "fmt" "io" "os" - "reflect" - "runtime" "runtime/debug" "time" - "github.com/pkg/errors" amqp "github.com/rabbitmq/amqp091-go" - - "github.com/sparetimecoders/goamqp/internal/handlers" ) -// HandlerFunc is used to process an incoming message -// If processing fails, an error should be returned and the message will be re-queued -// The optional response is used automatically when setting up a RequestResponseHandler, otherwise ignored -type HandlerFunc func(msg any, headers Headers) (response any, err error) - // Connection is a wrapper around the actual amqp.Connection and amqp.Channel type Connection struct { - started bool - serviceName string - amqpUri amqp.URI - connection amqpConnection - channel AmqpChannel - queueHandlers *handlers.QueueHandlers[messageHandlerInvoker] - // messageLogger defaults to noOpMessageLogger, can be overridden with UseMessageLogger - messageLogger MessageLogger - // errorLogF defaults to noOpLogger, can be overridden with UseLogger - errorLog errorLog - typeToKey map[reflect.Type]string - keyToType map[string]reflect.Type + started bool + serviceName string + amqpUri amqp.URI + connection amqpConnection + channel AmqpChannel + queueConsumers *queueConsumers + notificationCh chan<- Notification + errorCh chan<- ErrorNotification + spanNameFn func(DeliveryInfo) string } // ServiceResponsePublisher represents the function that is called to publish a response -type ServiceResponsePublisher func(ctx context.Context, targetService, routingKey string, msg any) error - -// QueueBindingConfig is a wrapper around the actual amqp queue configuration -type QueueBindingConfig struct { - routingKey string - handler HandlerFunc - eventType eventType - queueName string - exchangeName string - kind kind - headers amqp.Table -} - -// QueueBindingConfigSetup is a setup function that takes a QueueBindingConfig and provide custom changes to the -// configuration -type QueueBindingConfigSetup func(config *QueueBindingConfig) error - -// AddQueueNameSuffix appends the provided suffix to the queue name -// Useful when multiple consumers are needed for a routing key in the same service -func AddQueueNameSuffix(suffix string) QueueBindingConfigSetup { - return func(config *QueueBindingConfig) error { - if suffix == "" { - return ErrEmptySuffix - } - config.queueName = fmt.Sprintf("%s-%s", config.queueName, suffix) - return nil - } -} +type ServiceResponsePublisher[T any] func(ctx context.Context, targetService, routingKey string, event T) error var ( // ErrEmptySuffix returned when an empty suffix is passed ErrEmptySuffix = fmt.Errorf("empty queue suffix not allowed") // ErrAlreadyStarted returned when Start is called multiple times ErrAlreadyStarted = fmt.Errorf("already started") - // ErrIllegalEventType is returned when an illegal type is passed - ErrIllegalEventType = fmt.Errorf("passing reflect.TypeOf event types is not allowed") - // ErrNilLogger is returned if nil is passed as a logger func - ErrNilLogger = errors.New("cannot use nil as logger func") - // ErrRecoverable will not be logged during message processing - ErrRecoverable = errors.New("recoverable error") ) // NewFromURL creates a new Connection from an URL @@ -112,20 +65,9 @@ func NewFromURL(serviceName string, amqpURL string) (*Connection, error) { return newConnection(serviceName, uri), nil } -// Must is a helper that wraps a call to a function returning (*T, error) -// and panics if the error is non-nil. It is intended for use in variable -// initializations such as -// var c = goamqp.Must(goamqp.NewFromURL("service", "amqp://")) -func Must[T any](t *T, err error) *T { - if err != nil { - panic(err) - } - return t -} - // PublishServiceResponse sends a message to targetService as a handler response func (c *Connection) PublishServiceResponse(ctx context.Context, targetService, routingKey string, msg any) error { - return c.publishMessage(ctx, msg, routingKey, serviceResponseExchangeName(c.serviceName), amqp.Table{headerService: targetService}) + return publishMessage(ctx, c.channel, msg, routingKey, serviceResponseExchangeName(c.serviceName), amqp.Table{headerService: targetService}) } func (c *Connection) URI() amqp.URI { @@ -137,19 +79,15 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { if c.started { return ErrAlreadyStarted } - c.messageLogger = noOpMessageLogger() - c.errorLog = noOpLogger if c.channel == nil { err := c.connectToAmqpURL() if err != nil { return err } } - - if err := c.channel.Qos(20, 0, true); err != nil { + if err := c.channel.Qos(20, 0, false); err != nil { return err } - for _, f := range opts { if err := f(c); err != nil { return fmt.Errorf("setup function <%s> failed, %v", getSetupFuncName(f), err) @@ -172,54 +110,6 @@ func (c *Connection) Close() error { return c.connection.Close() } -func (c *Connection) TypeMappingHandler(handler HandlerFunc) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - routingKey := headers["routing-key"].(string) - typ, exists := c.keyToType[routingKey] - if !exists { - return nil, nil - } - body := []byte(*msg.(*json.RawMessage)) - message, err := c.parseMessage(body, typ) - if err != nil { - return nil, err - } else { - if resp, err := handler(message, headers); err == nil { - return resp, nil - } else { - return nil, err - } - } - } -} - -// AmqpChannel wraps the amqp.Channel to allow for mocking -type AmqpChannel interface { - QueueBind(queue, key, exchange string, noWait bool, args amqp.Table) error - Consume(queue, consumer string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) - ExchangeDeclare(name, kind string, durable, autoDelete, internal, noWait bool, args amqp.Table) error - // Deprecated: Use PublishWithContext instead. - Publish(exchange, key string, mandatory, immediate bool, msg amqp.Publishing) error - PublishWithContext(ctx context.Context, exchange, key string, mandatory, immediate bool, msg amqp.Publishing) error - QueueDeclare(name string, durable, autoDelete, exclusive, noWait bool, args amqp.Table) (amqp.Queue, error) - NotifyPublish(confirm chan amqp.Confirmation) chan amqp.Confirmation - NotifyClose(c chan *amqp.Error) chan *amqp.Error - Confirm(noWait bool) error - // Qos controls how many messages or how many bytes the server will try to keep on - // the network for consumers before receiving delivery acks. The intent of Qos is - // to make sure the network buffers stay full between the server and client. - // If your consumer work time is reasonably consistent and not much greater - // than two times your network round trip time, you will see significant - // throughput improvements starting with a prefetch count of 2 or slightly - // greater as described by benchmarks on RabbitMQ. - // - // http://www.rabbitmq.com/blog/2012/04/25/rabbitmq-performance-measurements-part-2/ - // The default prefetchCount is 20 (and global true) and can be overridden with WithPrefetchLimit - Qos(prefetchCount, prefetchSize int, global bool) error -} - -var _ AmqpChannel = &amqp.Channel{} - type amqpConnection interface { io.Closer Channel() (*amqp.Channel, error) @@ -231,7 +121,7 @@ func dialConfig(url string, cfg amqp.Config) (amqpConnection, error) { var dialAmqp = dialConfig -func amqpVersion() string { +func version() string { // NOTE: this doesn't work outside of a build, se we can't really test it if x, ok := debug.ReadBuildInfo(); ok { for _, y := range x.Deps { @@ -243,70 +133,13 @@ func amqpVersion() string { return "_unknown_" } -func responseWrapper(handler HandlerFunc, routingKey string, publisher ServiceResponsePublisher) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - resp, err := handler(msg, headers) - if err != nil { - return nil, errors.Wrap(err, "failed to process message") - } - if resp != nil { - service, err := sendingService(headers) - if err != nil { - return nil, errors.Wrap(err, "failed to extract service name") - } - // TODO Pass context to HandlerFunc instead and use here? - ctx := context.Background() - err = publisher(ctx, service, routingKey, resp) - if err != nil { - return nil, errors.Wrapf(err, "failed to publish response") - } - return resp, nil - } - return nil, nil - } -} - -func consume(channel AmqpChannel, queue string) (<-chan amqp.Delivery, error) { - return channel.Consume( - queue, - "", - false, - false, - false, - false, - amqp.Table{}, - ) -} - -func queueDeclare(channel AmqpChannel, name string) error { - _, err := channel.QueueDeclare(name, - true, - false, - false, - false, - queueDeclareExpiration, - ) - return err -} - -func transientQueueDeclare(channel AmqpChannel, name string) error { - _, err := channel.QueueDeclare(name, - false, - true, - true, - false, - queueDeclareExpiration, - ) - return err -} - func amqpConfig(serviceName string) amqp.Config { config := amqp.Config{ Properties: amqp.NewConnectionProperties(), Heartbeat: 10 * time.Second, Locale: "en_US", } - config.Properties.SetClientConnectionName(fmt.Sprintf("%s#%+v#@%s", serviceName, amqpVersion(), hostName())) + config.Properties.SetClientConnectionName(fmt.Sprintf("%s#%+v#@%s", serviceName, version(), hostName())) return config } @@ -335,174 +168,61 @@ func (c *Connection) connectToAmqpURL() error { return nil } -func (c *Connection) exchangeDeclare(channel AmqpChannel, name string, kind kind) error { - args := amqp.Table{} - - return channel.ExchangeDeclare( - name, - string(kind), - true, - false, - false, - false, - args, - ) -} - -func (c *Connection) addHandler(queueName, routingKey string, eventType eventType, mHI *messageHandlerInvoker) error { - return c.queueHandlers.Add(queueName, routingKey, mHI) -} - -func (c *Connection) handleMessage(d amqp.Delivery, handler HandlerFunc, eventType eventType) { - message, err := c.parseMessage(d.Body, eventType) - if err != nil { - c.errorLog(fmt.Sprintf("failed to parse message %s", err)) - _ = d.Reject(false) - } else { - if _, err := handler(message, headers(d.Headers, d.RoutingKey)); err == nil { - _ = d.Ack(false) - } else { - if !errors.Is(err, ErrRecoverable) { - c.errorLog(fmt.Sprintf("failed to process message %s", err)) - } - _ = d.Nack(false, true) - } - } -} - -func (c *Connection) parseMessage(jsonContent []byte, eventType eventType) (any, error) { - target := reflect.New(eventType).Interface() - if err := json.Unmarshal(jsonContent, &target); err != nil { - return nil, err - } - - return target, nil -} - -func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, exchangeName string, headers amqp.Table) error { - jsonBytes, err := json.Marshal(msg) - if err != nil { +func (c *Connection) messageHandlerBindQueueToExchange(cfg *consumerConfig) error { + if err := c.queueConsumers.add(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err } - c.messageLogger(jsonBytes, reflect.TypeOf(msg), routingKey, true) - - publishing := amqp.Publishing{ - Body: jsonBytes, - ContentType: contentType, - DeliveryMode: 2, - Headers: headers, - } - - return c.channel.PublishWithContext(ctx, exchangeName, - routingKey, - false, - false, - publishing, - ) -} - -func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, handlers *handlers.Handlers[messageHandlerInvoker]) { - for d := range deliveries { - if h, ok := handlers.Get(d.RoutingKey); ok { - c.messageLogger(d.Body, h.eventType, d.RoutingKey, false) - c.handleMessage(d, h.msgHandler, h.eventType) - } else { - // Unhandled message, drop it - _ = d.Reject(false) - } - } -} -func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) error { - if err := c.addHandler(cfg.queueName, cfg.routingKey, cfg.eventType, &messageHandlerInvoker{ - msgHandler: cfg.handler, - eventType: cfg.eventType, - }); err != nil { + if err := exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { return err } - - if err := c.exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { + if err := queueDeclare(c.channel, cfg); err != nil { return err } - if err := queueDeclare(c.channel, cfg.queueName); err != nil { - return err - } - return c.channel.QueueBind(cfg.queueName, cfg.routingKey, cfg.exchangeName, false, cfg.headers) + return c.channel.QueueBind(cfg.queueName, cfg.routingKey, cfg.exchangeName, false, cfg.queueBindingHeaders) } -type kind string +func exchangeDeclare(channel AmqpChannel, name string, kind string) error { + return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) +} -const ( - kindDirect = "direct" - kindHeaders = "headers" - kindTopic = "topic" -) +func queueDeclare(channel AmqpChannel, cfg *consumerConfig) error { + _, err := channel.QueueDeclare(cfg.queueName, true, false, false, false, cfg.queueHeaders) + return err +} const ( headerService = "service" - headerExpires = "x-expires" ) const contentType = "application/json" var ( - deleteQueueAfter = 5 * 24 * time.Hour - queueDeclareExpiration = amqp.Table{headerExpires: int(deleteQueueAfter.Seconds() * 1000)} + deleteQueueAfter = 5 * 24 * time.Hour + defaultQueueOptions = amqp.Table{ + amqp.QueueTypeArg: amqp.QueueTypeQuorum, + amqp.SingleActiveConsumerArg: true, + amqp.QueueTTLArg: int(deleteQueueAfter.Seconds() * 1000)} ) func newConnection(serviceName string, uri amqp.URI) *Connection { return &Connection{ - serviceName: serviceName, - amqpUri: uri, - queueHandlers: &handlers.QueueHandlers[messageHandlerInvoker]{}, - keyToType: make(map[string]reflect.Type), - typeToKey: make(map[reflect.Type]string), + serviceName: serviceName, + amqpUri: uri, + queueConsumers: &queueConsumers{ + consumers: make(map[string]*queueConsumer), + spanNameFn: spanNameFn, + }, } } func (c *Connection) setup() error { - for _, queue := range c.queueHandlers.Queues() { - consumer, err := consume(c.channel, queue.Name) - if err != nil { - return fmt.Errorf("failed to create consumer for queue %s. %v", queue.Name, err) + for _, consumer := range (*c).queueConsumers.consumers { + if deliveries, err := consumer.consume(c.channel, c.notificationCh, c.errorCh); err != nil { + return fmt.Errorf("failed to create consumer for queue %s. %v", consumer.queue, err) + } else { + go consumer.loop(deliveries) } - go c.divertToMessageHandlers(consumer, queue.Handlers) } return nil } - -func getEventType(eventType any) (eventType, error) { - if _, ok := eventType.(reflect.Type); ok { - return nil, ErrIllegalEventType - } - return reflect.TypeOf(eventType), nil -} - -type eventType reflect.Type - -type messageHandlerInvoker struct { - msgHandler HandlerFunc - eventType eventType -} - -// getSetupFuncName returns the name of the Setup function -func getSetupFuncName(f Setup) string { - return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() -} - -// getQueueBindingConfigSetupFuncName returns the name of the QueueBindingConfigSetup function -func getQueueBindingConfigSetupFuncName(f QueueBindingConfigSetup) string { - return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() -} - -// sendingService returns the name of the service that produced the message -// Can be used to send a handlerResponse, see PublishServiceResponse -func sendingService(headers Headers) (string, error) { - if h, exist := headers[headerService]; exist { - switch v := h.(type) { - case string: - return v, nil - } - } - return "", errors.New("no service found") -} diff --git a/connection_options.go b/connection_options.go deleted file mode 100644 index 094b1c6..0000000 --- a/connection_options.go +++ /dev/null @@ -1,292 +0,0 @@ -// MIT License -// -// Copyright (c) 2023 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package goamqp - -import ( - "fmt" - "reflect" - - "github.com/pkg/errors" - amqp "github.com/rabbitmq/amqp091-go" -) - -// Setup is a setup function that takes a Connection and use it to set up AMQP -// An example is to create exchanges and queues -type Setup func(conn *Connection) error - -// WithTypeMapping adds a two-way mapping between a type and a routing key. The mapping needs to be unique. -func WithTypeMapping(routingKey string, msgType any) Setup { - return func(conn *Connection) error { - typ := reflect.TypeOf(msgType) - if t, exists := conn.keyToType[routingKey]; exists { - return fmt.Errorf("mapping for routing key '%s' already registered to type '%s'", routingKey, t) - } - if key, exists := conn.typeToKey[typ]; exists { - return fmt.Errorf("mapping for type '%s' already registered to routing key '%s'", typ, key) - } - conn.keyToType[routingKey] = typ - conn.typeToKey[typ] = routingKey - return nil - } -} - -// CloseListener receives a callback when the AMQP Channel gets closed -func CloseListener(e chan error) Setup { - return func(c *Connection) error { - temp := make(chan *amqp.Error) - go func() { - for { - if ev := <-temp; ev != nil { - e <- errors.New(ev.Error()) - } - } - }() - c.channel.NotifyClose(temp) - return nil - } -} - -// UseLogger allows an errorLogf to be used to log errors during processing of messages -func UseLogger(logger errorLog) Setup { - return func(c *Connection) error { - if logger == nil { - return ErrNilLogger - } - c.errorLog = logger - return nil - } -} - -// UseMessageLogger allows a MessageLogger to be used when log in/outgoing messages -func UseMessageLogger(logger MessageLogger) Setup { - return func(c *Connection) error { - if logger == nil { - return ErrNilLogger - } - c.messageLogger = logger - return nil - } -} - -// WithPrefetchLimit configures the number of messages to prefetch from the server. -// To get round-robin behavior between consumers consuming from the same queue on -// different connections, set the prefetch count to 1, and the next available -// message on the server will be delivered to the next available consumer. -// If your consumer work time is reasonably consistent and not much greater -// than two times your network round trip time, you will see significant -// throughput improvements starting with a prefetch count of 2 or slightly -// greater, as described by benchmarks on RabbitMQ. -// -// http://www.rabbitmq.com/blog/2012/04/25/rabbitmq-performance-measurements-part-2/ -func WithPrefetchLimit(limit int) Setup { - return func(conn *Connection) error { - return conn.channel.Qos(limit, 0, true) - } -} - -// TransientEventStreamConsumer sets up an event stream consumer that will clean up resources when the -// connection is closed. -// For a durable queue, use the EventStreamConsumer function instead. -func TransientEventStreamConsumer(routingKey string, handler HandlerFunc, eventType any) Setup { - return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler, eventType) -} - -// EventStreamConsumer sets up ap a durable, persistent event stream consumer. -// For a transient queue, use the TransientEventStreamConsumer function instead. -func EventStreamConsumer(routingKey string, handler HandlerFunc, eventType any, opts ...QueueBindingConfigSetup) Setup { - return StreamConsumer(defaultEventExchangeName, routingKey, handler, eventType, opts...) -} - -// EventStreamPublisher sets up an event stream publisher -func EventStreamPublisher(publisher *Publisher) Setup { - return StreamPublisher(defaultEventExchangeName, publisher) -} - -// TransientStreamConsumer sets up an event stream consumer that will clean up resources when the -// connection is closed. -// For a durable queue, use the StreamConsumer function instead. -func TransientStreamConsumer(exchange, routingKey string, handler HandlerFunc, eventType any) Setup { - exchangeName := topicExchangeName(exchange) - return func(c *Connection) error { - eventTyp, err := getEventType(eventType) - if err != nil { - return err - } - queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) - if err := c.addHandler(queueName, routingKey, eventTyp, &messageHandlerInvoker{ - msgHandler: handler, - eventType: eventTyp, - }); err != nil { - return err - } - - if err := c.exchangeDeclare(c.channel, exchangeName, kindTopic); err != nil { - return err - } - if err := transientQueueDeclare(c.channel, queueName); err != nil { - return err - } - return c.channel.QueueBind(queueName, routingKey, exchangeName, false, amqp.Table{}) - } -} - -// StreamConsumer sets up ap a durable, persistent event stream consumer. -func StreamConsumer(exchange, routingKey string, handler HandlerFunc, eventType any, opts ...QueueBindingConfigSetup) Setup { - exchangeName := topicExchangeName(exchange) - return func(c *Connection) error { - eventTyp, err := getEventType(eventType) - if err != nil { - return err - } - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: handler, - eventType: eventTyp, - queueName: serviceEventQueueName(exchangeName, c.serviceName), - exchangeName: exchangeName, - kind: kindTopic, - headers: amqp.Table{}, - } - for _, f := range opts { - if err := f(config); err != nil { - return fmt.Errorf("queuebinding setup function <%s> failed, %v", getQueueBindingConfigSetupFuncName(f), err) - } - } - - return c.messageHandlerBindQueueToExchange(config) - } -} - -// StreamPublisher sets up an event stream publisher -func StreamPublisher(exchange string, publisher *Publisher) Setup { - name := topicExchangeName(exchange) - return func(c *Connection) error { - if err := c.exchangeDeclare(c.channel, name, kindTopic); err != nil { - return errors.Wrapf(err, "failed to declare exchange %s", name) - } - publisher.connection = c - if err := publisher.setDefaultHeaders(c.serviceName); err != nil { - return err - } - publisher.exchange = name - return nil - } -} - -// QueuePublisher sets up a publisher that will send events to a specific queue instead of using the exchange, -// so called Sender-Selected distribution -// https://www.rabbitmq.com/sender-selected.html#:~:text=The%20RabbitMQ%20broker%20treats%20the,key%20if%20they%20are%20present. -func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { - return func(c *Connection) error { - publisher.connection = c - if err := publisher.setDefaultHeaders(c.serviceName, - Header{Key: "CC", Value: []any{destinationQueueName}}, - ); err != nil { - return err - } - publisher.exchange = "" - return nil - } -} - -// ServiceResponseConsumer is a specialization of EventStreamConsumer -// It sets up ap a durable, persistent consumer (exchange->queue) for responses from targetService -func ServiceResponseConsumer(targetService, routingKey string, handler HandlerFunc, eventType any) Setup { - return func(c *Connection) error { - eventTyp, err := getEventType(eventType) - if err != nil { - return err - } - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: handler, - eventType: eventTyp, - queueName: serviceResponseQueueName(targetService, c.serviceName), - exchangeName: serviceResponseExchangeName(targetService), - kind: kindHeaders, - headers: amqp.Table{headerService: c.serviceName}, - } - - return c.messageHandlerBindQueueToExchange(config) - } -} - -// ServiceRequestConsumer is a specialization of EventStreamConsumer -// It sets up ap a durable, persistent consumer (exchange->queue) for message to the service owning the Connection -func ServiceRequestConsumer(routingKey string, handler HandlerFunc, eventType any) Setup { - return func(c *Connection) error { - eventTyp, err := getEventType(eventType) - if err != nil { - return err - } - resExchangeName := serviceResponseExchangeName(c.serviceName) - if err := c.exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { - return errors.Wrapf(err, "failed to create exchange %s", resExchangeName) - } - - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: handler, - eventType: eventTyp, - queueName: serviceRequestQueueName(c.serviceName), - exchangeName: serviceRequestExchangeName(c.serviceName), - kind: kindDirect, - headers: amqp.Table{}, - } - - return c.messageHandlerBindQueueToExchange(config) - } -} - -// ServicePublisher sets up ap a publisher, that sends messages to the targetService -func ServicePublisher(targetService string, publisher *Publisher) Setup { - return func(c *Connection) error { - reqExchangeName := serviceRequestExchangeName(targetService) - publisher.connection = c - if err := publisher.setDefaultHeaders(c.serviceName); err != nil { - return err - } - publisher.exchange = reqExchangeName - if err := c.exchangeDeclare(c.channel, reqExchangeName, kindDirect); err != nil { - return err - } - return nil - } -} - -// RequestResponseHandler is a convenience func to set up ServiceRequestConsumer and combines it with -// PublishServiceResponse -func RequestResponseHandler(routingKey string, handler HandlerFunc, eventType any) Setup { - return func(c *Connection) error { - responseHandlerWrapper := responseWrapper(handler, routingKey, c.PublishServiceResponse) - return ServiceRequestConsumer(routingKey, responseHandlerWrapper, eventType)(c) - } -} - -// PublishNotify see amqp.Channel.Confirm -func PublishNotify(confirm chan amqp.Confirmation) Setup { - return func(c *Connection) error { - c.channel.NotifyPublish(confirm) - return c.channel.Confirm(false) - } -} diff --git a/connection_options_test.go b/connection_options_test.go deleted file mode 100644 index d0ccf94..0000000 --- a/connection_options_test.go +++ /dev/null @@ -1,499 +0,0 @@ -// MIT License -// -// Copyright (c) 2023 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package goamqp - -import ( - "context" - "errors" - "reflect" - "runtime" - "testing" - - "github.com/google/uuid" - amqp "github.com/rabbitmq/amqp091-go" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_UseLogger(t *testing.T) { - conn := &Connection{errorLog: nil} - logger := noOpLogger - loggerName := runtime.FuncForPC(reflect.ValueOf(logger).Pointer()).Name() - require.NoError(t, UseLogger(logger)(conn)) - setLoggerName := runtime.FuncForPC(reflect.ValueOf(conn.errorLog).Pointer()).Name() - require.Equal(t, loggerName, setLoggerName) - - conn = &Connection{errorLog: nil} - require.EqualError(t, UseLogger(nil)(conn), "cannot use nil as logger func") - require.Nil(t, conn.errorLog) -} - -func Test_CloseListener(t *testing.T) { - listener := make(chan error) - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := CloseListener(listener)(conn) - require.NoError(t, err) - require.Equal(t, true, channel.NotifyCloseCalled) - // nil is ignored - channel.ForceClose(nil) - channel.ForceClose(&amqp.Error{Code: 123, Reason: "Close reason"}) - err = <-listener - require.EqualError(t, err, "Exception (123) Reason: \"Close reason\"") -} - -func Test_EventStreamPublisher_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - conn.typeToKey[reflect.TypeOf(TestMessage{})] = "key" - conn.typeToKey[reflect.TypeOf(TestMessage2{})] = "key2" - p := NewPublisher() - err := EventStreamPublisher(p)(conn) - require.NoError(t, err) - - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "events.topic.exchange", noWait: false, internal: false, autoDelete: false, durable: true, kind: "topic", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 0, len(channel.QueueDeclarations)) - require.Equal(t, 0, len(channel.BindingDeclarations)) - - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - - published := <-channel.Published - require.Equal(t, "key", published.key) - - err = p.PublishWithContext(context.Background(), TestMessage{Msg: "test"}, Header{"x-header", "header"}) - require.NoError(t, err) - published = <-channel.Published - - require.Equal(t, 2, len(published.msg.Headers)) - require.Equal(t, "svc", published.msg.Headers["service"]) - require.Equal(t, "header", published.msg.Headers["x-header"]) -} - -func Test_QueuePublisher_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - conn.typeToKey[reflect.TypeOf(TestMessage{})] = "key" - conn.typeToKey[reflect.TypeOf(TestMessage2{})] = "key2" - p := NewPublisher() - err := QueuePublisher(p, "destQueue")(conn) - require.NoError(t, err) - - require.Equal(t, 0, len(channel.ExchangeDeclarations)) - - require.Equal(t, 0, len(channel.QueueDeclarations)) - require.Equal(t, 0, len(channel.BindingDeclarations)) - - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - - published := <-channel.Published - require.Equal(t, "key", published.key) - - err = p.PublishWithContext(context.Background(), TestMessage{Msg: "test"}, Header{"x-header", "header"}) - require.NoError(t, err) - published = <-channel.Published - - require.Equal(t, 3, len(published.msg.Headers)) - require.Equal(t, "svc", published.msg.Headers["service"]) - require.Equal(t, "header", published.msg.Headers["x-header"]) - require.Equal(t, "destQueue", published.msg.Headers["CC"].([]any)[0]) -} - -func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - p := NewPublisher() - - e := errors.New("failed to create exchange") - channel.ExchangeDeclarationError = &e - err := EventStreamPublisher(p)(conn) - require.Error(t, err) - require.EqualError(t, err, "failed to declare exchange events.topic.exchange: failed to create exchange") -} - -func Test_UseMessageLogger(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - conn.typeToKey[reflect.TypeOf(TestMessage{})] = "routingkey" - logger := &MockLogger{} - p := NewPublisher() - - _ = conn.Start(context.Background(), - UseMessageLogger(logger.logger()), - ServicePublisher("service", p), - ) - require.NotNil(t, conn.messageLogger) - - err := p.PublishWithContext(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - <-channel.Published - - require.Equal(t, true, logger.outgoing) - require.Equal(t, "routingkey", logger.routingKey) - require.Equal(t, reflect.TypeOf(TestMessage{}), logger.eventType) -} - -func Test_UseMessageLogger_Nil(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - p := NewPublisher() - - err := conn.Start(context.Background(), - UseMessageLogger(nil), - ServicePublisher("service", p), - ) - require.Error(t, err) -} - -func Test_UseMessageLogger_Default(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - p := NewPublisher() - - err := conn.Start(context.Background(), - ServicePublisher("service", p), - ) - require.NoError(t, err) - require.NotNil(t, conn.messageLogger) -} - -func Test_EventStreamConsumer(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{})) - require.NoError(t, err) - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "events.topic.exchange", noWait: false, internal: false, autoDelete: false, durable: true, kind: "topic", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "events.topic.exchange.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "events.topic.exchange.queue.svc", noWait: false, exchange: "events.topic.exchange", key: "key", args: amqp.Table{}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(channel.Consumers)) - require.Equal(t, Consumer{queue: "events.topic.exchange.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}, channel.Consumers[0]) -} - -func Test_EventStreamConsumerWithOptFunc(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{}, AddQueueNameSuffix("suffix"))) - require.NoError(t, err) - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "events.topic.exchange", noWait: false, internal: false, autoDelete: false, durable: true, kind: "topic", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "events.topic.exchange.queue.svc-suffix", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "events.topic.exchange.queue.svc-suffix", noWait: false, exchange: "events.topic.exchange", key: "key", args: amqp.Table{}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(channel.Consumers)) - require.Equal(t, Consumer{queue: "events.topic.exchange.queue.svc-suffix", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}, channel.Consumers[0]) -} - -func Test_EventStreamConsumerWithFailingOptFunc(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{}, AddQueueNameSuffix(""))) - require.ErrorContains(t, err, "failed, empty queue suffix not allowed") -} - -func Test_ServiceRequestConsumer_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{})) - - require.NoError(t, err) - require.Equal(t, 2, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "svc.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, channel.ExchangeDeclarations[1]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "svc.direct.exchange.request.queue", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: amqp.Table{}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(channel.Consumers)) - require.Equal(t, Consumer{queue: "svc.direct.exchange.request.queue", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}, channel.Consumers[0]) -} - -func Test_ServiceRequestConsumer_ExchangeDeclareError(t *testing.T) { - channel := NewMockAmqpChannel() - declareError := errors.New("failed") - channel.ExchangeDeclarationError = &declareError - conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{})) - - require.ErrorContains(t, err, "failed, failed to create exchange svc.headers.exchange.response: failed") -} - -func Test_ServiceResponseConsumer_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{})) - - require.NoError(t, err) - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "targetService.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "targetService.headers.exchange.response.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "targetService.headers.exchange.response.queue.svc", noWait: false, exchange: "targetService.headers.exchange.response", key: "key", args: amqp.Table{headerService: "svc"}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(channel.Consumers)) - require.Equal(t, Consumer{queue: "targetService.headers.exchange.response.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}, channel.Consumers[0]) -} - -func Test_ServiceResponseConsumer_ExchangeDeclareError(t *testing.T) { - channel := NewMockAmqpChannel() - declareError := errors.New("actual error message") - channel.ExchangeDeclarationError = &declareError - conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(i any, headers Headers) (any, error) { - return nil, nil - }, TestMessage{})) - - require.ErrorContains(t, err, " failed, actual error message") -} - -func Test_RequestResponseHandler(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := RequestResponseHandler("key", func(msg any, headers Headers) (response any, err error) { - return nil, nil - }, Message{})(conn) - require.NoError(t, err) - - require.Equal(t, 2, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "svc.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, channel.ExchangeDeclarations[1]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "svc.direct.exchange.request.queue", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: amqp.Table{}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(conn.queueHandlers.Queues())) - - invoker, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").Get("key") - require.Equal(t, reflect.TypeOf(Message{}), invoker.eventType) - require.Equal(t, "github.com/sparetimecoders/goamqp.responseWrapper.func1", runtime.FuncForPC(reflect.ValueOf(invoker.msgHandler).Pointer()).Name()) -} - -func Test_ServicePublisher_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - conn.typeToKey[reflect.TypeOf(TestMessage{})] = "key" - p := NewPublisher() - - err := ServicePublisher("svc", p)(conn) - require.NoError(t, err) - - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 0, len(channel.QueueDeclarations)) - require.Equal(t, 0, len(channel.BindingDeclarations)) - - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - published := <-channel.Published - require.Equal(t, "key", published.key) - require.Equal(t, "svc.direct.exchange.request", published.exchange) -} - -func Test_ServicePublisher_Multiple(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - conn.typeToKey[reflect.TypeOf(TestMessage{})] = "key" - conn.typeToKey[reflect.TypeOf(TestMessage2{})] = "key2" - p := NewPublisher() - - err := ServicePublisher("svc", p)(conn) - require.NoError(t, err) - - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 0, len(channel.QueueDeclarations)) - require.Equal(t, 0, len(channel.BindingDeclarations)) - - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - err = p.PublishWithContext(context.Background(), TestMessage2{Msg: "msg"}) - require.NoError(t, err) - err = p.PublishWithContext(context.Background(), TestMessage{"test2", false}) - require.NoError(t, err) - published := <-channel.Published - require.Equal(t, "key", published.key) - require.Equal(t, "svc.direct.exchange.request", published.exchange) - published = <-channel.Published - require.Equal(t, "key2", published.key) - require.Equal(t, "svc.direct.exchange.request", published.exchange) - published = <-channel.Published - require.Equal(t, "key", published.key) - require.Equal(t, "{\"Msg\":\"test2\",\"Success\":false}", string(published.msg.Body)) -} - -func Test_ServicePublisher_NoMatchingRoute(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - p := NewPublisher() - - err := ServicePublisher("svc", p)(conn) - require.NoError(t, err) - - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 0, len(channel.QueueDeclarations)) - require.Equal(t, 0, len(channel.BindingDeclarations)) - - err = p.PublishWithContext(context.Background(), &TestMessage{Msg: "test"}) - require.True(t, errors.Is(err, ErrNoRouteForMessageType)) - require.EqualError(t, err, "no routingkey configured for message of type *goamqp.TestMessage") -} - -func Test_ServicePublisher_ExchangeDeclareFail(t *testing.T) { - e := errors.New("failed") - channel := NewMockAmqpChannel() - channel.ExchangeDeclarationError = &e - conn := mockConnection(channel) - - p := NewPublisher() - - err := ServicePublisher("svc", p)(conn) - require.Error(t, err) - require.EqualError(t, err, e.Error()) -} - -func Test_TransientEventStreamConsumer_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(i any, headers Headers) (any, error) { - return nil, errors.New("failed") - }, Message{})(conn) - - require.NoError(t, err) - require.Equal(t, 1, len(channel.BindingDeclarations)) - require.Equal(t, BindingDeclaration{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: amqp.Table{}}, channel.BindingDeclarations[0]) - - require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "events.topic.exchange", kind: "topic", durable: true, autoDelete: false, internal: false, noWait: false, args: amqp.Table{}}, channel.ExchangeDeclarations[0]) - - require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, exclusive: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(conn.queueHandlers.Queues())) - invoker, _ := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").Get("key") - require.Equal(t, reflect.TypeOf(Message{}), invoker.eventType) -} - -func Test_TransientEventStreamConsumer_HandlerForRoutingKeyAlreadyExists(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - require.NoError(t, conn.queueHandlers.Add("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", "root.key", &messageHandlerInvoker{})) - - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("root.#", func(i any, headers Headers) (any, error) { - return nil, errors.New("failed") - }, Message{})(conn) - - require.EqualError(t, err, "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix") -} - -func Test_TransientEventStreamConsumer_ExchangeDeclareFails(t *testing.T) { - channel := NewMockAmqpChannel() - e := errors.New("failed") - channel.ExchangeDeclarationError = &e - - testTransientEventStreamConsumerFailure(t, channel, e.Error()) -} - -func Test_TransientEventStreamConsumer_QueueDeclareFails(t *testing.T) { - channel := NewMockAmqpChannel() - e := errors.New("failed to create queue") - channel.QueueDeclarationError = &e - testTransientEventStreamConsumerFailure(t, channel, e.Error()) -} - -func testTransientEventStreamConsumerFailure(t *testing.T, channel *MockAmqpChannel, expectedError string) { - conn := mockConnection(channel) - - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(i any, headers Headers) (any, error) { - return nil, errors.New("failed") - }, Message{})(conn) - - require.EqualError(t, err, expectedError) -} - -func Test_PublishNotify(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - notifier := make(chan amqp.Confirmation) - err := PublishNotify(notifier)(conn) - require.NoError(t, err) - require.Equal(t, ¬ifier, channel.Confirms) - require.Equal(t, true, channel.ConfirmCalled) -} - -func Test_WithTypeMapping_KeyAlreadyExist(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := WithTypeMapping("key", TestMessage{})(conn) - assert.NoError(t, err) - err = WithTypeMapping("key", TestMessage2{})(conn) - assert.EqualError(t, err, "mapping for routing key 'key' already registered to type 'goamqp.TestMessage'") -} - -func Test_WithTypeMapping_TypeAlreadyExist(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := WithTypeMapping("key", TestMessage{})(conn) - assert.NoError(t, err) - err = WithTypeMapping("other", TestMessage{})(conn) - assert.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to routing key 'key'") -} diff --git a/connection_test.go b/connection_test.go index 877eecd..8be59b2 100644 --- a/connection_test.go +++ b/connection_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -28,27 +28,14 @@ import ( "errors" "fmt" "math" - "reflect" "testing" amqp "github.com/rabbitmq/amqp091-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - - handlers2 "github.com/sparetimecoders/goamqp/internal/handlers" ) func Test_AmqpVersion(t *testing.T) { - require.Equal(t, "_unknown_", amqpVersion()) -} - -func Test_getEventType(t *testing.T) { - e, err := getEventType(TestMessage{}) - require.NoError(t, err) - require.Equal(t, reflect.TypeOf(TestMessage{}), e) - - _, err = getEventType(reflect.TypeOf(TestMessage{})) - require.Error(t, err) + require.Equal(t, "_unknown_", version()) } func Test_Start_MultipleCallsFails(t *testing.T) { @@ -60,9 +47,10 @@ func Test_Start_MultipleCallsFails(t *testing.T) { }, } conn := &Connection{ - serviceName: "test", - connection: mockAmqpConnection, - channel: mockChannel, + serviceName: "test", + connection: mockAmqpConnection, + channel: mockChannel, + queueConsumers: &queueConsumers{}, } err := conn.Start(context.Background()) require.NoError(t, err) @@ -88,53 +76,73 @@ func Test_Start_SettingDefaultQosFails(t *testing.T) { require.EqualError(t, err, "error setting qos") } -func Test_Start_SetupFails(t *testing.T) { - mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} - mockChannel := &MockAmqpChannel{ - consumeFn: func(queue, consumer string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) { - return nil, errors.New("error consuming queue") +func Test_messageHandlerBindQueueToExchange(t *testing.T) { + tests := []struct { + name string + queueDeclarationError error + exchangeDeclarationError error + }{ + { + name: "ok", + }, + { + name: "queue declare error", + queueDeclarationError: errors.New("failed to create queue"), + }, + { + name: "exchange declare error", + exchangeDeclarationError: errors.New("failed to create exchange"), }, } - conn := &Connection{ - serviceName: "test", - connection: mockAmqpConnection, - channel: mockChannel, - queueHandlers: &handlers2.QueueHandlers[messageHandlerInvoker]{}, + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := &MockAmqpChannel{ + QueueDeclarationError: &tt.queueDeclarationError, + ExchangeDeclarationError: &tt.exchangeDeclarationError, + } + conn := mockConnection(channel) + cfg := &consumerConfig{ + routingKey: "routingkey", + handler: nil, + queueName: "queue", + exchangeName: "exchange", + kind: amqp.ExchangeDirect, + queueBindingHeaders: nil, + queueHeaders: nil, + } + err := conn.messageHandlerBindQueueToExchange(cfg) + if tt.queueDeclarationError != nil { + require.ErrorIs(t, err, tt.queueDeclarationError) + } else if tt.exchangeDeclarationError != nil { + require.ErrorIs(t, err, tt.exchangeDeclarationError) + } else { + require.NoError(t, err) + } + }) } - err := conn.Start(context.Background(), - EventStreamConsumer("test", func(i any, headers Headers) (any, error) { - return nil, errors.New("failed") - }, Message{})) - require.Error(t, err) - require.EqualError(t, err, "failed to create consumer for queue events.topic.exchange.queue.test. error consuming queue") } -func Test_Start_WithPrefetchLimit_Resets_Qos(t *testing.T) { +func Test_Start_SetupFails(t *testing.T) { mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} mockChannel := &MockAmqpChannel{ - qosFn: func(cc int) func(prefetchCount, prefetchSize int, global bool) error { - return func(prefetchCount, prefetchSize int, global bool) error { - defer func() { - cc++ - }() - if cc == 0 { - require.Equal(t, 20, prefetchCount) - } else { - require.Equal(t, 1, prefetchCount) - } - return nil - } - }(0), + consumeFn: func(queue, consumer string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) { + return nil, errors.New("error consuming queue") + }, } conn := &Connection{ serviceName: "test", connection: mockAmqpConnection, channel: mockChannel, + queueConsumers: &queueConsumers{ + consumers: make(map[string]*queueConsumer), + }, } err := conn.Start(context.Background(), - WithPrefetchLimit(1), - ) - require.NoError(t, err) + EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) error { + return errors.New("failed") + })) + require.Error(t, err) + require.EqualError(t, err, "failed to create consumer for queue events.topic.exchange.queue.test. error consuming queue") } func Test_Start_ConnectionFail(t *testing.T) { @@ -150,18 +158,6 @@ func Test_Start_ConnectionFail(t *testing.T) { require.EqualError(t, err, "failed to connect") } -func Test_Must(t *testing.T) { - conn := Must(NewFromURL("", "amqp://user:password@localhost:67333/a")) - require.NotNil(t, conn) - - defer func() { - if r := recover(); r == nil { - t.Errorf("The code did not panic") - } - }() - _ = Must(NewFromURL("", "invalid")) -} - func Test_URI(t *testing.T) { conn := Must(NewFromURL("", "amqp://user:password@localhost:67333/a")) require.NotNil(t, conn) @@ -251,52 +247,47 @@ func Test_AmqpConfig(t *testing.T) { func Test_QueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, "test") + err := queueDeclare(channel, &consumerConfig{ + queueName: "test", + exchangeName: "test", + queueHeaders: defaultQueueOptions}) require.NoError(t, err) require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "test", durable: true, autoDelete: false, noWait: false, args: amqp.Table{"x-expires": int(deleteQueueAfter.Seconds() * 1000)}}, channel.QueueDeclarations[0]) + require.Equal(t, QueueDeclaration{name: "test", durable: true, autoDelete: false, exclusive: false, noWait: false, args: defaultQueueOptions}, channel.QueueDeclarations[0]) } func Test_TransientQueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := transientQueueDeclare(channel, "test") + err := queueDeclare(channel, &consumerConfig{ + queueName: "test", + exchangeName: "test", + queueHeaders: defaultQueueOptions}) require.NoError(t, err) require.Equal(t, 1, len(channel.QueueDeclarations)) - require.Equal(t, QueueDeclaration{name: "test", durable: false, autoDelete: true, exclusive: true, noWait: false, args: amqp.Table{"x-expires": int(deleteQueueAfter.Seconds() * 1000)}}, channel.QueueDeclarations[0]) + require.Equal(t, QueueDeclaration{name: "test", durable: true, autoDelete: false, exclusive: false, noWait: false, args: defaultQueueOptions}, channel.QueueDeclarations[0]) } func Test_ExchangeDeclare(t *testing.T) { channel := NewMockAmqpChannel() - conn := mockConnection(channel) - - err := conn.exchangeDeclare(channel, "name", "topic") + err := exchangeDeclare(channel, "name", "topic") require.NoError(t, err) require.Equal(t, 1, len(channel.ExchangeDeclarations)) - require.Equal(t, ExchangeDeclaration{name: "name", kind: "topic", durable: true, autoDelete: false, noWait: false, args: amqp.Table{}}, channel.ExchangeDeclarations[0]) + require.Equal(t, ExchangeDeclaration{name: "name", kind: "topic", durable: true, autoDelete: false, noWait: false, args: nil}, channel.ExchangeDeclarations[0]) } -func Test_Consume(t *testing.T) { +func Test_Publish_Fail(t *testing.T) { channel := NewMockAmqpChannel() - _, err := consume(channel, "q") - require.NoError(t, err) - require.Equal(t, 1, len(channel.Consumers)) - require.Equal(t, Consumer{ - queue: "q", - consumer: "", autoAck: false, exclusive: false, noLocal: false, noWait: false, args: amqp.Table{}, - }, channel.Consumers[0]) + err := publishMessage(context.Background(), channel, Message{true}, "failed", "exchange", nil) + require.EqualError(t, err, "failed") } func Test_Publish(t *testing.T) { channel := NewMockAmqpChannel() headers := amqp.Table{} headers["key"] = "value" - c := Connection{ - channel: channel, - messageLogger: noOpMessageLogger(), - } - err := c.publishMessage(context.Background(), Message{true}, "key", "exchange", headers) + err := publishMessage(context.Background(), channel, Message{true}, "key", "exchange", headers) require.NoError(t, err) publish := <-channel.Published @@ -328,11 +319,7 @@ func Test_Publish_Marshal_Error(t *testing.T) { channel := NewMockAmqpChannel() headers := amqp.Table{} headers["key"] = "value" - c := Connection{ - channel: channel, - messageLogger: noOpMessageLogger(), - } - err := c.publishMessage(context.Background(), math.Inf(1), "key", "exchange", headers) + err := publishMessage(context.Background(), channel, math.Inf(1), "key", "exchange", headers) require.EqualError(t, err, "json: unsupported value: +Inf") } @@ -360,30 +347,30 @@ func TestResponseWrapper(t *testing.T) { name: "handler ok - with resp - publish error", handlerResp: Message{}, publisherErr: errors.New("amqp error"), - wantErr: errors.New("failed to publish response: amqp error"), + wantErr: errors.New("failed to publish response, amqp error"), }, { name: "handler error - no resp - nothing published", handlerErr: errors.New("failed"), - wantErr: errors.New("failed to process message: failed"), + wantErr: errors.New("failed to process message, failed"), }, { name: "handler error - with resp - nothing published", handlerResp: Message{}, handlerErr: errors.New("failed"), - wantErr: errors.New("failed to process message: failed"), + wantErr: errors.New("failed to process message, failed"), }, { name: "handler ok - with resp - missing header", handlerResp: Message{}, headers: &Headers{}, - wantErr: errors.New("failed to extract service name: no service found"), + wantErr: errors.New("failed to extract service name, no service found"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &mockPublisher{ + p := &mockPublisher[any]{ err: tt.publisherErr, published: nil, } @@ -392,12 +379,14 @@ func TestResponseWrapper(t *testing.T) { if tt.headers != nil { headers = *tt.headers } - resp, err := responseWrapper(func(i any, headers Headers) (any, error) { + err := responseWrapper(func(ctx context.Context, event ConsumableEvent[Message]) (any, error) { return tt.handlerResp, tt.handlerErr - }, "key", p.publish)(&Message{}, headers) + }, "key", p.publish)(context.TODO(), ConsumableEvent[Message]{ + DeliveryInfo: DeliveryInfo{Headers: headers}, + }) p.checkPublished(t, tt.published) - require.Equal(t, tt.wantResp, resp) + // require.Equal(t, tt.wantResp, resp) if tt.wantErr != nil { require.EqualError(t, tt.wantErr, err.Error()) } @@ -405,156 +394,22 @@ func TestResponseWrapper(t *testing.T) { } } -func Test_DivertToMessageHandler(t *testing.T) { - acker := MockAcknowledger{ - Acks: make(chan Ack, 4), - Nacks: make(chan Nack, 1), - Rejects: make(chan Reject, 1), - } - channel := MockAmqpChannel{Published: make(chan Publish, 1)} - - handlers := &handlers2.QueueHandlers[messageHandlerInvoker]{} - msgInvoker := &messageHandlerInvoker{ - eventType: reflect.TypeOf(Message{}), - msgHandler: func(i any, headers Headers) (any, error) { - if i.(*Message).Ok { - return nil, nil - } - return nil, errors.New("failed") - }, - } - require.NoError(t, handlers.Add("q", "key1", msgInvoker)) - require.NoError(t, handlers.Add("q", "key2", msgInvoker)) - - queueDeliveries := make(chan amqp.Delivery, 6) - - queueDeliveries <- delivery(acker, "key1", true) - queueDeliveries <- delivery(acker, "key2", true) - queueDeliveries <- delivery(acker, "key2", false) - queueDeliveries <- delivery(acker, "missing", true) - close(queueDeliveries) - - c := Connection{ - started: true, - channel: &channel, - messageLogger: noOpMessageLogger(), - errorLog: noOpLogger, - } - c.divertToMessageHandlers(queueDeliveries, handlers.Queues()[0].Handlers) - - require.Equal(t, 1, len(acker.Rejects)) - require.Equal(t, 1, len(acker.Nacks)) - require.Equal(t, 2, len(acker.Acks)) -} - -func Test_messageHandlerBindQueueToExchange(t *testing.T) { - e := errors.New("failed to create queue") - channel := &MockAmqpChannel{ - QueueDeclarationError: &e, - } - conn := mockConnection(channel) - - cfg := &QueueBindingConfig{ - routingKey: "routingkey", - handler: nil, - eventType: nil, - queueName: "queue", - exchangeName: "exchange", - kind: kindDirect, - headers: nil, - } - err := conn.messageHandlerBindQueueToExchange(cfg) - require.EqualError(t, err, "failed to create queue") -} - -func delivery(acker MockAcknowledger, routingKey string, success bool) amqp.Delivery { - body, _ := json.Marshal(Message{success}) - - return amqp.Delivery{ - Body: body, - RoutingKey: routingKey, - Acknowledger: &acker, - } -} - -func Test_HandleMessage_Ack_WhenHandled(t *testing.T) { - require.Equal(t, Ack{tag: 0x0, multiple: false}, <-testHandleMessage("{}", true).Acks) -} - -func Test_HandleMessage_Nack_WhenUnhandled(t *testing.T) { - require.Equal(t, Nack{tag: 0x0, multiple: false, requeue: true}, <-testHandleMessage("{}", false).Nacks) -} - -func Test_HandleMessage_Reject_IfParseFails(t *testing.T) { - require.Equal(t, Reject{tag: 0x0, requeue: false}, <-testHandleMessage("", true).Rejects) -} - -func testHandleMessage(json string, handle bool) MockAcknowledger { - type Message struct{} - acker := NewMockAcknowledger() - delivery := amqp.Delivery{ - Body: []byte(json), - Acknowledger: &acker, - } - c := &Connection{ - messageLogger: noOpMessageLogger(), - errorLog: noOpLogger, - } - c.handleMessage(delivery, func(i any, headers Headers) (any, error) { - if handle { - return nil, nil - } - return nil, errors.New("failed") - }, reflect.TypeOf(Message{})) - return acker -} - -func Test_HandleMessage_RecoverableError(t *testing.T) { - var logged bool - type Message struct{} - acker := NewMockAcknowledger() - delivery := amqp.Delivery{ - Body: []byte("{}"), - Acknowledger: &acker, - } - c := &Connection{ - messageLogger: noOpMessageLogger(), - errorLog: func(s string) { - logged = true - }, - } - c.handleMessage(delivery, func(i any, headers Headers) (any, error) { - return nil, fmt.Errorf("error: %w", ErrRecoverable) - }, reflect.TypeOf(Message{})) - require.False(t, logged) -} - func Test_Publisher_ReservedHeader(t *testing.T) { p := NewPublisher() - err := p.PublishWithContext(context.Background(), TestMessage{Msg: "test"}, Header{"service", "header"}) + err := p.Publish(context.Background(), "key", TestMessage{Msg: "test"}, Header{"service", "header"}) require.EqualError(t, err, "reserved key service used, please change to use another one") } -func TestEmptyQueueNameSuffix(t *testing.T) { - require.EqualError(t, AddQueueNameSuffix("")(&QueueBindingConfig{}), ErrEmptySuffix.Error()) -} - -func TestQueueNameSuffix(t *testing.T) { - cfg := &QueueBindingConfig{queueName: "queue"} - require.NoError(t, AddQueueNameSuffix("suffix")(cfg)) - require.Equal(t, "queue-suffix", cfg.queueName) -} - type Message struct { Ok bool } -type mockPublisher struct { +type mockPublisher[R any] struct { err error - published any + published R } -func (m *mockPublisher) publish(ctx context.Context, targetService, routingKey string, msg any) error { +func (m *mockPublisher[R]) publish(ctx context.Context, targetService, routingKey string, msg R) error { if m.err != nil { return m.err } @@ -562,119 +417,6 @@ func (m *mockPublisher) publish(ctx context.Context, targetService, routingKey s return nil } -func (m *mockPublisher) checkPublished(t *testing.T, i any) { +func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { require.EqualValues(t, m.published, i) } - -func TestConnection_TypeMappingHandler(t *testing.T) { - type fields struct { - typeToKey map[reflect.Type]string - keyToType map[string]reflect.Type - } - type args struct { - handler func(t *testing.T) HandlerFunc - msg json.RawMessage - key string - } - tests := []struct { - name string - fields fields - args args - want any - wantErr assert.ErrorAssertionFunc - }{ - { - name: "no mapped type, ignored", - fields: fields{}, - args: args{ - msg: []byte(`{"a":true}`), - key: "unknown", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - return nil, nil - } - }, - }, - want: nil, - wantErr: assert.NoError, - }, - { - name: "parse error", - fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, - }, - args: args{ - msg: []byte(`{"a:}`), - key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - return nil, nil - } - }, - }, - want: nil, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "unexpected end of JSON input") - }, - }, - { - name: "handler error", - fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, - }, - args: args{ - msg: []byte(`{"a":true}`), - key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - assert.IsType(t, &TestMessage{}, msg) - return nil, fmt.Errorf("handler-error") - } - }, - }, - want: nil, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "handler-error") - }, - }, - { - name: "success", - fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, - }, - args: args{ - msg: []byte(`{"a":true}`), - key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { - assert.IsType(t, &TestMessage{}, msg) - return "OK", nil - } - }, - }, - want: "OK", - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - c := &Connection{ - typeToKey: tt.fields.typeToKey, - keyToType: tt.fields.keyToType, - } - - handler := c.TypeMappingHandler(tt.args.handler(t)) - res, err := handler(&tt.args.msg, headers(make(amqp.Table), tt.args.key)) - if !tt.wantErr(t, err) { - return - } - assert.Equalf(t, tt.want, res, "TypeMappingHandler()") - }) - } -} diff --git a/consumableEvent.go b/consumableEvent.go new file mode 100644 index 0000000..6ca93c1 --- /dev/null +++ b/consumableEvent.go @@ -0,0 +1,60 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "encoding/json" + "time" +) + +// Metadata holds the metadata of an event. +type Metadata struct { + ID string `json:"id"` + CorrelationID string `json:"correlationId"` + Timestamp time.Time `json:"timestamp"` +} + +// DeliveryInfo holds information of original queue, exchange and routing keys. +type DeliveryInfo struct { + Queue string + Exchange string + RoutingKey string + Headers Headers +} + +// ConsumableEvent represents an event that can be consumed. +// The type parameter T specifies the type of the event's payload. +type ConsumableEvent[T any] struct { + Metadata + DeliveryInfo DeliveryInfo + Payload T +} + +// unmarshalEvent is used internally to unmarshal a PublishableEvent +// this way the payload ends up being a json.RawMessage instead of map[string]interface{} +// so that later the json.RawMessage can be unmarshal to ConsumableEvent[T].Payload. +type unmarshalEvent struct { + Metadata + DeliveryInfo DeliveryInfo + Payload json.RawMessage +} diff --git a/consumer.go b/consumer.go new file mode 100644 index 0000000..5840d34 --- /dev/null +++ b/consumer.go @@ -0,0 +1,135 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "errors" + "fmt" + "time" + + amqp "github.com/rabbitmq/amqp091-go" + "go.opentelemetry.io/otel" +) + +type queueConsumer struct { + queue string + handlers routingKeyHandler + notificationCh chan<- Notification + errorCh chan<- ErrorNotification + spanNameFn func(info DeliveryInfo) string +} + +func (c *queueConsumer) consume(channel AmqpChannel, notificationCh chan<- Notification, errorCh chan<- ErrorNotification) (<-chan amqp.Delivery, error) { + c.notificationCh = notificationCh + c.errorCh = errorCh + deliveries, err := channel.Consume(c.queue, "", false, false, false, false, nil) + if err != nil { + return nil, err + } + return deliveries, nil +} + +func (c *queueConsumer) loop(deliveries <-chan amqp.Delivery) { + for delivery := range deliveries { + deliveryInfo := getDeliveryInfo(c.queue, delivery) + eventReceived(c.queue, deliveryInfo.RoutingKey) + + // Establish which handler is invoked + handler, ok := c.handlers.get(deliveryInfo.RoutingKey) + if !ok { + eventWithoutHandler(c.queue, deliveryInfo.RoutingKey) + _ = delivery.Reject(false) + continue + } + c.handleDelivery(handler, delivery, deliveryInfo) + } +} + +func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Delivery, deliveryInfo DeliveryInfo) { + headerCtx := extractToContext(delivery.Headers) + tracingCtx, span := otel.Tracer("amqp").Start(headerCtx, c.spanNameFn(deliveryInfo)) + defer span.End() + startTime := time.Now() + + uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} + if err := handler(tracingCtx, uevt); err != nil { + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerFailed(c.errorCh, deliveryInfo, elapsed, err) + if errors.Is(err, ErrParseJSON) { + eventNotParsable(deliveryInfo.Queue, deliveryInfo.RoutingKey) + _ = delivery.Nack(false, false) + } else if errors.Is(err, ErrNoMessageTypeForRouteKey) { + eventWithoutHandler(deliveryInfo.Queue, deliveryInfo.RoutingKey) + _ = delivery.Reject(false) + } else { + eventNack(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) + _ = delivery.Nack(false, true) + } + return + } + + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerSucceed(c.notificationCh, deliveryInfo, elapsed) + _ = delivery.Ack(false) + eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) +} + +type queueConsumers struct { + consumers map[string]*queueConsumer + spanNameFn func(info DeliveryInfo) string +} + +func (c *queueConsumers) get(queueName, routingKey string) (wrappedHandler, bool) { + consumerForQueue, ok := (*c).consumers[queueName] + if !ok { + return nil, false + } + return consumerForQueue.handlers.get(routingKey) +} + +func (c *queueConsumers) add(queueName, routingKey string, handler wrappedHandler) error { + consumerForQueue, ok := (*c).consumers[queueName] + if !ok { + consumerForQueue = &queueConsumer{ + queue: queueName, + handlers: make(routingKeyHandler), + spanNameFn: c.spanNameFn, + } + (*c).consumers[queueName] = consumerForQueue + } + if mappedRoutingKey, exists := consumerForQueue.handlers.exists(routingKey); exists { + return fmt.Errorf("routingkey %s overlaps %s for queue %s, consider using AddQueueNameSuffix", routingKey, mappedRoutingKey, queueName) + } + consumerForQueue.handlers.add(routingKey, handler) + return nil +} + +func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { + deliveryInfo := DeliveryInfo{ + Queue: queueName, + Exchange: delivery.Exchange, + RoutingKey: delivery.RoutingKey, + Headers: Headers(delivery.Headers), + } + return deliveryInfo +} diff --git a/consumer_test.go b/consumer_test.go new file mode 100644 index 0000000..441f82f --- /dev/null +++ b/consumer_test.go @@ -0,0 +1,217 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "testing" + + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +//func Test_No_Unmarshalling_Payload_Any(t *testing.T) { +// type A struct { +// A string `json:"a"` +// } +// err := newWrappedHandler(func(ctx context.Context, event ConsumableEvent[any]) error { +// var a A +// _ = json.Unmarshal(event.Payload.(json.RawMessage), &a) +// require.Equal(t, a.A, "b") +// return nil +// })(context.TODO(), unmarshalEvent{Payload: []byte(`{"a":"b"}`)}) +// require.NoError(t, err) +//} +// +//func Test_No_Unmarshalling_Payload_JsonRaw(t *testing.T) { +// type A struct { +// A string `json:"a"` +// } +// err := newWrappedHandler(func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { +// var a A +// _ = json.Unmarshal(event.Payload, &a) +// require.Equal(t, a.A, "b") +// return nil +// })(context.TODO(), unmarshalEvent{Payload: []byte(`{"a":"b"}`)}) +// require.NoError(t, err) +//} + +func Test_Invalid_Payload(t *testing.T) { + err := newWrappedHandler(func(ctx context.Context, event ConsumableEvent[string]) error { + return nil + })(context.TODO(), unmarshalEvent{Payload: []byte(`{"a":}`)}) + require.ErrorIs(t, err, ErrParseJSON) + require.ErrorContains(t, err, "invalid character '}' looking for beginning of value") +} + +func Test_Consume(t *testing.T) { + consumer := queueConsumer{ + queue: "aQueue", + handlers: routingKeyHandler{}, + } + channel := &MockAmqpChannel{consumeFn: func(queue, consumerName string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) { + require.Equal(t, consumer.queue, queue) + require.Equal(t, "", consumerName) + require.False(t, autoAck) + require.False(t, exclusive) + require.False(t, noLocal) + require.False(t, noWait) + require.Nil(t, args) + deliveries := make(chan amqp.Delivery, 1) + deliveries <- amqp.Delivery{ + MessageId: "MESSAGE_ID", + } + close(deliveries) + return deliveries, nil + }} + + deliveries, err := consumer.consume(channel, nil, nil) + require.NoError(t, err) + delivery := <-deliveries + require.Equal(t, "MESSAGE_ID", delivery.MessageId) +} + +func Test_Consume_Failing(t *testing.T) { + consumer := queueConsumer{ + queue: "aQueue", + handlers: routingKeyHandler{}, + } + channel := &MockAmqpChannel{consumeFn: func(queue, consumerName string, autoAck, exclusive, noLocal, noWait bool, args amqp.Table) (<-chan amqp.Delivery, error) { + return nil, fmt.Errorf("failed") + }} + + _, err := consumer.consume(channel, nil, nil) + require.EqualError(t, err, "failed") +} + +func Test_ConsumerLoop(t *testing.T) { + acker := MockAcknowledger{ + Acks: make(chan Ack, 2), + Nacks: make(chan Nack, 1), + Rejects: make(chan Reject, 1), + } + handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { + if msg.Payload.Ok { + return nil + } + return errors.New("failed") + }) + + consumer := queueConsumer{ + handlers: routingKeyHandler{}, + spanNameFn: func(info DeliveryInfo) string { + return "span" + }, + } + consumer.handlers.add("key1", handler) + consumer.handlers.add("key2", handler) + + queueDeliveries := make(chan amqp.Delivery, 4) + + queueDeliveries <- delivery(acker, "key1", true) + queueDeliveries <- delivery(acker, "key2", true) + queueDeliveries <- delivery(acker, "key2", false) + queueDeliveries <- delivery(acker, "missing", true) + close(queueDeliveries) + + consumer.loop(queueDeliveries) + + require.Len(t, acker.Rejects, 1) + require.Len(t, acker.Nacks, 1) + require.Len(t, acker.Acks, 2) +} + +func Test_HandleDelivery(t *testing.T) { + tests := []struct { + name string + error error + numberOfAcks int + numberOfNacks int + numberOfRejects int + }{ + { + name: "ok", + numberOfAcks: 1, + }, + { + name: "invalid JSON", + error: ErrParseJSON, + numberOfNacks: 1, + }, + { + name: "no match for routingkey", + error: ErrNoMessageTypeForRouteKey, + numberOfRejects: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + errorNotifications := make(chan ErrorNotification, 1) + notifications := make(chan Notification, 1) + consumer := queueConsumer{ + errorCh: errorNotifications, + notificationCh: notifications, + spanNameFn: func(info DeliveryInfo) string { + return "span" + }, + } + deliveryInfo := DeliveryInfo{ + RoutingKey: "key", + } + acker := MockAcknowledger{ + Acks: make(chan Ack, 1), + Nacks: make(chan Nack, 1), + Rejects: make(chan Reject, 1), + } + handler := func(ctx context.Context, event unmarshalEvent) error { + return tt.error + } + d := delivery(acker, "routingKey", true) + consumer.handleDelivery(handler, d, deliveryInfo) + if tt.error != nil { + notification := <-errorNotifications + require.EqualError(t, notification.Error, tt.error.Error()) + } else { + notification := <-notifications + require.Contains(t, notification.DeliveryInfo.RoutingKey, "key") + } + + require.Len(t, acker.Acks, tt.numberOfAcks) + require.Len(t, acker.Nacks, tt.numberOfNacks) + require.Len(t, acker.Rejects, tt.numberOfRejects) + }) + } +} + +func delivery(acker MockAcknowledger, routingKey string, success bool) amqp.Delivery { + body, _ := json.Marshal(Message{success}) + + return amqp.Delivery{ + Body: body, + RoutingKey: routingKey, + Acknowledger: &acker, + } +} diff --git a/doc.go b/doc.go index 64a7201..413d0a0 100644 --- a/doc.go +++ b/doc.go @@ -1,21 +1,24 @@ -// Copyright (c) 2019 sparetimecoders +// MIT License // -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -// the Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. // Package goamqp provides an opiniated way of using [rabbitmq](https://www.rabbitmq.com/) for event-driven architectures. package goamqp diff --git a/docs/README.md b/docs/README.md index d3c4a37..a7c310f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,7 +34,7 @@ anything. ```go conn, _ := goamqp.NewFromURL("our-service", "amqp://user:password@localhost:5672/") -_ = conn.Start() +_ = conn.Start(context.Background()) _ = conn.Close() ``` @@ -42,20 +42,18 @@ To actually create `exchanges`, `queues` and `bindings` we must pass one or more ### Publishing -The `Publisher` is used to send a message (event) of a certain type. +The `Publisher` is used to send a messages (events) of a certain types. ```go type AccountCreated struct { Name string `json:"name"` } -publisher := goamqp.Must( - goamqp.NewPublisher(goamqp.Route{ - Type: AccountCreated{}, - Key: "Account.Created", - })) - - +publisher := goamqp.NewPublisher() +conn.Start( + context.Background(), + goamqp.WithTypeMapping("Account.Created", AccountCreated{}), +) ``` This will create a `Publisher` that will publish `AccountCreated` messages with an associated routing key @@ -69,14 +67,17 @@ In this simple case we send messages to the default `event stream` by using the `EventStreamPublisher`. ```go -_ = conn.Start(goamqp.EventStreamPublisher(publisher)) +conn.Start( + context.Background(), + goamqp.WithTypeMapping("Account.Created", AccountCreated{}), + goamqp.EventStreamPublisher(publisher)) ``` Now, when we call `Start` entities will be created on the message broker. A new exchange called `events.topic.exchange` will be created (if it doesn't already exist). Now when we do: ```go -_ = publisher.Publish(&AccountCreated{Name: "test"}) +publisher.Publish(&AccountCreated{Name: "test"}) ``` the `AccountCreated` struct will be marshalled into JSON: @@ -96,12 +97,13 @@ Let's consume messages (in the same service, i.e. we send a message to ourselves using the `Setup` func `EventStreamConsumer`. ```go -_ = conn.Start( - goamqp.EventStreamPublisher(publisher), - goamqp.EventStreamConsumer("Account.Created", func(msg any, headers goamqp.Headers) (response any, err error) { - fmt.Println("Message received") - return nil, nil - }, AccountCreated{})) +conn.Start( + context.Background(), + goamqp.EventStreamPublisher(publisher), + goamqp.EventStreamConsumer("Account.Created", func(ctx context.Context, event goamqp.ConsumableEvent[AccountCreated]) error { + fmt.Println("Message received") + return nil + })) ``` @@ -118,40 +120,40 @@ The complete simple program below will send (and receive) a message and print it ``` Message received &{test} ``` + ```go package main import ( - "fmt" - "time" + "context" + "fmt" + "time" - "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/goamqp" ) type AccountCreated struct { - Name string `json:"name"` + Name string `json:"name"` } func main() { - publisher := goamqp.Must( - goamqp.NewPublisher(goamqp.Route{ - Type: AccountCreated{}, - Key: "Account.Created", - })) + publisher := goamqp.NewPublisher() - conn, _ := goamqp.NewFromURL("our-service", "amqp://user:password@localhost:5672/") + conn := goamqp.Must(goamqp.NewFromURL("our-service", "amqp://user:password@localhost:5672/")) - _ = conn.Start( - goamqp.EventStreamPublisher(publisher), - goamqp.EventStreamConsumer("Account.Created", func(msg any, headers goamqp.Headers) (response any, err error) { - fmt.Printf("Message received %s", msg) - return nil, nil - }, AccountCreated{})) + _ = conn.Start( + context.Background(), + goamqp.EventStreamPublisher(publisher), + goamqp.WithTypeMapping("Account.Created", AccountCreated{}), + goamqp.EventStreamConsumer("Account.Created", func(ctx context.Context, event goamqp.ConsumableEvent[AccountCreated]) error { + fmt.Printf("Message received %s", event.Payload.Name) + return nil + })) - _ = publisher.Publish(&AccountCreated{Name: "test"}) + _ = publisher.Publish(context.Background(), &AccountCreated{Name: "test"}) - time.Sleep(time.Second) - _ = conn.Close() + time.Sleep(time.Second) + _ = conn.Close() } ``` diff --git a/docs/message_processing.md b/docs/message_processing.md index ff436bb..5b87fe6 100644 --- a/docs/message_processing.md +++ b/docs/message_processing.md @@ -1,47 +1,36 @@ ## Message processing and error handling -The `HandlerFunc` is called by goamqp when a message arrive in a queue. +A registered `EventHandler` is called by goamqp when an event arrives on a queue. ```go -type HandlerFunc func(msg any, headers Headers) (response any, err error) +EventHandler[T any] func (ctx context.Context, event ConsumableEvent[T]) error ``` -For most purposes an application is only interested in the `msg` parameter, which will be our consumed message. Most -implementations will look like this: +For most purposes a handler is just interested in the `event.Payload`. +You register a handler using one of the `..Consumer` functions, for example: ```go -func handler(msg any, headers Headers) (response any, err error) { - switch msg.(type) { - case *Message: - default: - fmt.Println("Unknown message type") - } - return nil, nil +goamqp.EventStreamConsumer("Order.Created", func(ctx context.Context, event goamqp.ConsumableEvent[Message]) error { + fmt.Printf("handled %s", event.Payload.Text) + return nil +}) ``` -The `msg` will be a pointer to the type specified when calling `EventStreamConsumer`, for example: +For request-response, use the `RequestResponseEventHandler` and register it with the `RequestResponseHandler`: + ```go -EventStreamConsumer("Order.Created", handler, Message{}) +RequestResponseHandler[T any, R any](routingKey string, handler RequestResponseEventHandler[T, R]) ``` -For normal event processing the returned `response` is ignored. -The same `HandlerFunc` is used for request-response handlers however, for example: - ```go -RequestResponseHandler(routingKey, handleRequest, Request{}) - -func handleRequest(msg any, headers Headers) (response any, err error) { - return Response{}}, nil +goamqp.RequestResponseHandler("req.resp", func (ctx context.Context, event goamqp.ConsumableEvent[Request]) (Response, error) { + return Response{Output: event.Payload.Input}, nil +}) ``` -And in this case the returned `response` (`Response{}` in the code above) will be returned to the calling service. - -Returning `nil` as error will Acknowledge the message and it will be removed from the queue. - ### Errors -If anything but `nil` is returned from `HandlerFunc` the message will be rejected and requeued (which means that it will -be processed again). - -If goamqp fails to unmarshal the JSON content in the message, the message will be rejected and **not** requeued again. +If anything but `nil` is returned from `EventHandler` the event is considered Not Acknowledge and re-queued (which means +that it will be processed again). +If unmarshal the JSON payload in the event, the event will be rejected but **not** re-queued again. diff --git a/docs/naming.md b/docs/naming.md index ad3199d..b5408f8 100644 --- a/docs/naming.md +++ b/docs/naming.md @@ -47,4 +47,3 @@ A service that is listening for incoming *responses* will consume messages from ## References For full reference take a look at the [code](../naming.go) and [tests](../naming_test.go) - diff --git a/example_test.go b/example_test.go index 994e302..8519594 100644 --- a/example_test.go +++ b/example_test.go @@ -1,34 +1,35 @@ -// Copyright (c) 2019 sparetimecoders +// MIT License // -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -// the Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. -package goamqp_test +package goamqp import ( "context" "fmt" "os" "time" - - . "github.com/sparetimecoders/goamqp" ) -var amqpURL = "amqp://user:password@localhost:5672/" +var amqpURL = "amqp://user:password@localhost:5672" func Example() { ctx := context.Background() @@ -39,12 +40,11 @@ func Example() { connection := Must(NewFromURL("service", amqpURL)) err := connection.Start(ctx, - WithTypeMapping("key", IncomingMessage{}), - EventStreamConsumer("key", process, IncomingMessage{}), + EventStreamConsumer("key", process), EventStreamPublisher(publisher), ) checkError(err) - err = publisher.PublishWithContext(ctx, IncomingMessage{"OK"}) + err = publisher.Publish(ctx, "key", IncomingMessage{"OK"}) checkError(err) time.Sleep(time.Second) err = connection.Close() @@ -58,9 +58,9 @@ func checkError(err error) { } } -func process(m any, headers Headers) (any, error) { - fmt.Printf("Called process with %v\n", m.(*IncomingMessage).Data) - return nil, nil +func process(ctx context.Context, m ConsumableEvent[IncomingMessage]) error { + fmt.Printf("Called process with %v\n", m.Payload.Data) + return nil } type IncomingMessage struct { diff --git a/examples/event-stream/README.md b/examples/event-stream/README.md index 400b2f0..be4d089 100644 --- a/examples/event-stream/README.md +++ b/examples/event-stream/README.md @@ -6,24 +6,25 @@ and routing keys. Let's start with the `Publisher` ```go -orderPublisher := Must(NewPublisher( - Route{Type: OrderCreated{}, Key: "Order.Created"}, - Route{Type: OrderUpdated{}, Key: "Order.Updated"})) +orderPublisher := NewPublisher() ``` -The created `orderPublisher` can now be used to publish both `OrderCreated` and `OrderCreated` for different routing -keys. -Let's attach it to the stream +The created `orderPublisher` can now be used to publish both `OrderCreated` and `OrderCreated` for different routing +keys when we start. ```go -orderServiceConnection.Start(EventStreamPublisher(orderPublisher)) +orderServiceConnection.Start( + EventStreamPublisher(orderPublisher), + WithTypeMapping("Order.Created", OrderCreated{}), + WithTypeMapping("Order.Updated", OrderUpdated{}), +) ``` No we can publish events: ```go -orderPublisher.Publish(OrderCreated{Id: "id"}) -orderPublisher.Publish(OrderUpdated{Id: "id"}) +orderPublisher.Publish(context.Background(), OrderCreated{Id: "id"}) +orderPublisher.Publish(context.Background(), OrderUpdated{Id: "id", Data: "data"}) ``` Since no one is consuming the events they will of course just be dropped. Let's set up some consumers as well @@ -31,28 +32,32 @@ Since no one is consuming the events they will of course just be dropped. Let's The Stat service is only interested in created orders, so we just consume those events: ```go connection = Must(NewFromURL("stat-service", amqpURL)) -connection.Start( - EventStreamConsumer("Order.Created", handleOrderEvent, OrderCreated{}), +connection.Start(ctx, + EventStreamConsumer("Order.Created", s.handleOrderCreated), ) + ... -func handleOrderEvent(msg any, headers Headers) (response any, err error) { - switch msg.(type) { - case *OrderCreated: - fmt.Println("Increasing order count") - default: - fmt.Println("Unknown message type") - } - return nil, nil +func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) error { + fmt.Printf("Created order: %s", msg.Payload.Id) + return nil } ``` The Shipping service is interested in all events for orders: ```go -connection = Must(NewFromURL("shipping-service", amqpURL)) -connection.Start( - EventStreamConsumer("Order.Created", s.handleOrderEvent, OrderCreated{}), - EventStreamConsumer("Order.Updated", s.handleOrderEvent, OrderUpdated{}), +connection.Start(ctx, + WithTypeMapping("Order.Created", OrderCreated{}), + WithTypeMapping("Order.Updated", OrderUpdated{}), + EventStreamConsumer("#", TypeMappingHandler(func(ctx context.Context, event ConsumableEvent[any]) error { + switch event.Payload.(type) { + case *OrderCreated: + s.output = append(s.output, "Order created") + case *OrderUpdated: + s.output = append(s.output, "Order deleted") + } + return nil + }), ) ... diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index cfcb468..7f5dde0 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -1,21 +1,24 @@ -// Copyright (c) 2019 sparetimecoders +// MIT License // -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -// the Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. package event_stream @@ -23,22 +26,24 @@ import ( "context" "fmt" "os" + "reflect" "time" - . "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/goamqp" ) -var amqpURL = "amqp://user:password@localhost:5672/test" +var amqpURL = "amqp://user:password@localhost:5672" func Example_event_stream() { ctx := context.Background() if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { amqpURL = urlFromEnv } - orderServiceConnection := Must(NewFromURL("order-service", amqpURL)) - orderPublisher := NewPublisher() + orderServiceConnection := goamqp.Must(goamqp.NewFromURL("order-service", amqpURL)) + orderPublisher := goamqp.NewPublisher() + err := orderServiceConnection.Start(ctx, - EventStreamPublisher(orderPublisher), + goamqp.EventStreamPublisher(orderPublisher), ) checkError(err) @@ -50,20 +55,25 @@ func Example_event_stream() { err = statService.Start(ctx) checkError(err) - err = orderPublisher.PublishWithContext(context.Background(), OrderCreated{Id: "id"}) + err = orderPublisher.Publish(context.Background(), "Order.Created", OrderCreated{Id: "id"}) checkError(err) - err = orderPublisher.PublishWithContext(context.Background(), OrderUpdated{Id: "id"}) + err = orderPublisher.Publish(context.Background(), "Order.Updated", OrderUpdated{Id: "id", Data: "data"}) checkError(err) - time.Sleep(2 * time.Second) _ = orderServiceConnection.Close() - _ = shippingService.Stop() _ = statService.Stop() + + fmt.Println(statService.output) + fmt.Println(shippingService.output) + // Output: + // [Created order: id Updated order id: id - data] + // [Order created Order deleted] } // -- StatService type StatService struct { - connection *Connection + connection *goamqp.Connection + output []string } func (s *StatService) Stop() error { @@ -71,27 +81,27 @@ func (s *StatService) Stop() error { } func (s *StatService) Start(ctx context.Context) error { - s.connection = Must(NewFromURL("stat-service", amqpURL)) + s.connection = goamqp.Must(goamqp.NewFromURL("stat-service", amqpURL)) return s.connection.Start(ctx, - EventStreamConsumer("Order.Created", s.handleOrderEvent, OrderCreated{}), + goamqp.EventStreamConsumer("Order.Created", s.handleOrderCreated), + goamqp.EventStreamConsumer("Order.Updated", s.handleOrderUpdated), ) } -func (s *StatService) handleOrderEvent(msg any, headers Headers) (response any, err error) { - switch msg.(type) { - case *OrderCreated: - // Just to make sure the Output is correct in the example... - time.Sleep(time.Second) - fmt.Println("Increasing order count") - default: - fmt.Println("Unknown message type") - } - return nil, nil +func (s *StatService) handleOrderUpdated(ctx context.Context, msg goamqp.ConsumableEvent[OrderUpdated]) error { + s.output = append(s.output, fmt.Sprintf("Updated order id: %s - %s", msg.Payload.Id, msg.Payload.Data)) + return nil +} + +func (s *StatService) handleOrderCreated(ctx context.Context, msg goamqp.ConsumableEvent[OrderCreated]) error { + s.output = append(s.output, fmt.Sprintf("Created order: %s", msg.Payload.Id)) + return nil } // -- ShippingService type ShippingService struct { - connection *Connection + connection *goamqp.Connection + output []string } func (s *ShippingService) Stop() error { @@ -99,23 +109,26 @@ func (s *ShippingService) Stop() error { } func (s *ShippingService) Start(ctx context.Context) error { - s.connection = Must(NewFromURL("shipping-service", amqpURL)) + s.connection = goamqp.Must(goamqp.NewFromURL("shipping-service", amqpURL)) return s.connection.Start(ctx, - EventStreamConsumer("Order.Created", s.handleOrderEvent, OrderCreated{}), - EventStreamConsumer("Order.Updated", s.handleOrderEvent, OrderUpdated{}), - ) -} - -func (s *ShippingService) handleOrderEvent(msg any, headers Headers) (response any, err error) { - switch msg.(type) { - case *OrderCreated: - fmt.Println("Order created") - case *OrderUpdated: - fmt.Println("Order deleted") - default: - fmt.Println("Unknown message type") - } - return nil, nil + goamqp.EventStreamConsumer("#", goamqp.TypeMappingHandler(func(ctx context.Context, event goamqp.ConsumableEvent[any]) error { + switch event.Payload.(type) { + case *OrderCreated: + s.output = append(s.output, "Order created") + case *OrderUpdated: + s.output = append(s.output, "Order deleted") + } + return nil + }, func(ctx context.Context, routingKey string) (reflect.Type, bool) { + if routingKey == "Order.Created" { + return reflect.TypeOf(&OrderCreated{}), true + } + if routingKey == "Order.Updated" { + return reflect.TypeOf(&OrderUpdated{}), true + } + + return nil, false + }))) } func checkError(err error) { @@ -128,9 +141,6 @@ type OrderCreated struct { Id string } type OrderUpdated struct { - Id string -} - -type ShippingUpdated struct { - Id string + Id string + Data string } diff --git a/examples/request-response/README.md b/examples/request-response/README.md index dba94c9..e6360e2 100644 --- a/examples/request-response/README.md +++ b/examples/request-response/README.md @@ -1,28 +1,27 @@ # Request response -For a request-response set up the server creates a `RequestResponseHandler` with a `HandlerFunc` for a particular type. +For a request-response set up the server creates a `RequestResponseHandler` with a `EventHandler` for a particular type. ```go -func handleRequest(m any, headers Headers) (any, error) { - request := m.(*Request) - response := Response{Data: request.Data} +func handleRequest(ctx context.Context, m goamqp.ConsumableEvent[Request]) (any, error) { + response := Response{Data: m.Payload.Data} return response, nil } - -RequestResponseHandler('key', handleRequest, Request{}) +RequestResponseHandler(routingKey, handleRequest), ``` A client who wants to send a request to the server needs to publish a message with a `ServicePublisher` and handle the response with a `ServiceResponseConsumer`. ```go -func handleResponse(m any, headers Headers) (any, error) { - response := m.(*Response) - return nil, nil +func handleResponse(ctx context.Context, m goamqp.ConsumableEvent[Response]) error { + fmt.Printf("Got response, %v", m.Payload.Data) + return nil } +WithTypeMapping(routingKey, Request{}), ServicePublisher("service", publisher), -ServiceResponseConsumer("service", routingKey, handleResponse, Response{}), +ServiceResponseConsumer("service", routingKey, handleResponse) ``` ## AMQP diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index 86326cb..268cf61 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -1,21 +1,24 @@ -// Copyright (c) 2019 sparetimecoders +// MIT License // -// Permission is hereby granted, free of charge, to any person obtaining a copy of -// this software and associated documentation files (the "Software"), to deal in -// the Software without restriction, including without limitation the rights to -// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -// the Software, and to permit persons to whom the Software is furnished to do so, -// subject to the following conditions: +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. package request_response @@ -25,10 +28,10 @@ import ( "os" "time" - . "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/goamqp" ) -var amqpURL = "amqp://user:password@localhost:5672/test" +var amqpURL = "amqp://user:password@localhost:5672" func Example_request_response() { ctx := context.Background() @@ -36,27 +39,31 @@ func Example_request_response() { amqpURL = urlFromEnv } routingKey := "key" - serviceConnection := Must(NewFromURL("service", amqpURL)) + serviceConnection := goamqp.Must(goamqp.NewFromURL("service", amqpURL)) err := serviceConnection.Start(ctx, - RequestResponseHandler(routingKey, handleRequest, Request{}), + goamqp.RequestResponseHandler(routingKey, handleRequest), ) checkError(err) - clientConnection := Must(NewFromURL("client", amqpURL)) - publisher := NewPublisher() + clientConnection := goamqp.Must(goamqp.NewFromURL("client", amqpURL)) + publisher := goamqp.NewPublisher() err = clientConnection.Start(ctx, - ServicePublisher("service", publisher), - ServiceResponseConsumer("service", routingKey, handleResponse, Response{}), + goamqp.ServicePublisher("service", publisher), + goamqp.ServiceResponseConsumer("service", routingKey, handleResponse), ) checkError(err) - err = publisher.PublishWithContext(context.Background(), Request{Data: "test"}) + err = publisher.Publish(context.Background(), "key", Request{Data: "test"}) checkError(err) time.Sleep(time.Second) _ = serviceConnection.Close() _ = clientConnection.Close() + + // Output: + // Called process with test, returning response {test} + // Got response, test } func checkError(err error) { @@ -65,17 +72,15 @@ func checkError(err error) { } } -func handleRequest(m any, headers Headers) (any, error) { - request := m.(*Request) - response := Response{Data: request.Data} - fmt.Printf("Called process with %v, returning response %v\n", request.Data, response) +func handleRequest(ctx context.Context, m goamqp.ConsumableEvent[Request]) (any, error) { + response := Response{Data: m.Payload.Data} + fmt.Printf("Called process with %v, returning response %v\n", m.Payload.Data, response) return response, nil } -func handleResponse(m any, headers Headers) (any, error) { - response := m.(*Response) - fmt.Printf("Got response, returning response %v\n", response.Data) - return nil, nil +func handleResponse(ctx context.Context, m goamqp.ConsumableEvent[Response]) error { + fmt.Printf("Got response, %v\n", m.Payload.Data) + return nil } type Request struct { diff --git a/go.mod b/go.mod index f8231fb..f08c7d9 100644 --- a/go.mod +++ b/go.mod @@ -1,16 +1,34 @@ module github.com/sparetimecoders/goamqp -go 1.22.12 +go 1.23.0 + +toolchain go1.24.2 require ( github.com/google/uuid v1.6.0 - github.com/pkg/errors v0.9.1 + github.com/prometheus/client_golang v1.18.0 github.com/rabbitmq/amqp091-go v1.10.0 github.com/stretchr/testify v1.10.0 + go.opentelemetry.io/otel v1.22.0 + go.opentelemetry.io/otel/sdk v1.22.0 + go.opentelemetry.io/otel/trace v1.22.0 + go.uber.org/goleak v1.3.0 ) require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-logr/logr v1.4.1 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.5.0 // indirect + github.com/prometheus/common v0.45.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect + go.opentelemetry.io/otel/metric v1.22.0 // indirect + golang.org/x/sys v0.33.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bd7681e..0b02733 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,61 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ= +github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= +github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk= +github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= +github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lneoxM= +github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGyv9MZjVOJsY= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= +github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.opentelemetry.io/otel v1.22.0 h1:xS7Ku+7yTFvDfDraDIJVpw7XPyuHlB9MCiqqX5mcJ6Y= +go.opentelemetry.io/otel v1.22.0/go.mod h1:eoV4iAi3Ea8LkAEI9+GFT44O6T/D0GWAVFyZVCC6pMI= +go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er4d8Hhg= +go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= +go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB2Gw= +go.opentelemetry.io/otel/sdk v1.22.0/go.mod h1:iu7luyVGYovrRpe2fmj3CVKouQNdTOkxtLzPvPz1DOc= +go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx83XD0= +go.opentelemetry.io/otel/trace v1.22.0/go.mod h1:RbbHXVqKES9QhzZq/fE5UnOSILqRt40a21sPw2He1xo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/handler.go b/handler.go new file mode 100644 index 0000000..762c327 --- /dev/null +++ b/handler.go @@ -0,0 +1,66 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "reflect" +) + +var ErrParseJSON = errors.New("failed to parse") + +// wrappedHandler is internally used to wrap the generic EventHandler +// this is to facilitate adding all the different type of T on the same map +type wrappedHandler func(ctx context.Context, event unmarshalEvent) error + +func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler { + var t T + t1 := reflect.TypeOf(t) + if t1 == nil || t1.String() == "json.RawMessage" { + // Map to any/json.RawMessage requested, dont unmarshal + return func(ctx context.Context, event unmarshalEvent) error { + consumableEvent := ConsumableEvent[T]{ + Metadata: event.Metadata, + DeliveryInfo: event.DeliveryInfo, + } + consumableEvent.Payload = any(event.Payload).(T) + return handler(ctx, consumableEvent) + } + } + + return func(ctx context.Context, event unmarshalEvent) error { + consumableEvent := ConsumableEvent[T]{ + Metadata: event.Metadata, + DeliveryInfo: event.DeliveryInfo, + } + + err := json.Unmarshal(event.Payload, &consumableEvent.Payload) + if err != nil { + return fmt.Errorf("%v: %w", err, ErrParseJSON) + } + return handler(ctx, consumableEvent) + } +} diff --git a/headers.go b/headers.go index 8f4d674..4a7c0bc 100644 --- a/headers.go +++ b/headers.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -23,10 +23,8 @@ package goamqp import ( + "errors" "fmt" - - "github.com/pkg/errors" - amqp "github.com/rabbitmq/amqp091-go" ) // Header represent meta-data for the message @@ -36,6 +34,8 @@ type Header struct { Value any } +var ErrEmptyHeaderKey = errors.New("empty key not allowed") + // Headers represent all meta-data for the message type Headers map[string]any @@ -49,7 +49,7 @@ func (h Headers) Get(key string) any { func (h Header) validateKey() error { if len(h.Key) == 0 || h.Key == "" { - return errors.New("empty key not allowed") + return ErrEmptyHeaderKey } for _, rh := range reservedHeaderKeys { if rh == h.Key { @@ -69,12 +69,4 @@ func (h Headers) validate() error { return nil } -func headers(headers amqp.Table, routingKey string) Headers { - if headers == nil { - headers = make(amqp.Table) - } - headers["routing-key"] = routingKey - return Headers(headers) -} - var reservedHeaderKeys = []string{headerService} diff --git a/headers_test.go b/headers_test.go index 6cf4309..0019d8c 100644 --- a/headers_test.go +++ b/headers_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2023 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,6 @@ package goamqp import ( "testing" - amqp "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" ) @@ -33,22 +32,21 @@ func Test_Headers(t *testing.T) { h := Headers{} require.NoError(t, h.validate()) - h = headers(amqp.Table{"valid": ""}, "") + h = Headers{"valid": ""} require.NoError(t, h.validate()) require.Equal(t, "", h.Get("valid")) require.Nil(t, h.Get("invalid")) - h = headers(amqp.Table{"valid1": "1", "valid2": "2"}, "key") + h = Headers{"valid1": "1", "valid2": "2"} require.Equal(t, "1", h.Get("valid1")) require.Equal(t, "2", h.Get("valid2")) - require.Equal(t, "key", h.Get("routing-key")) h = map[string]any{headerService: "p"} require.EqualError(t, h.validate(), "reserved key service used, please change to use another one") h = map[string]any{"": "p"} - require.EqualError(t, h.validate(), "empty key not allowed") + require.ErrorIs(t, h.validate(), ErrEmptyHeaderKey) - h = headers(amqp.Table{headerService: "peter"}, "") - require.Equal(t, h.Get(headerService), "peter") + h = Headers{headerService: "aService"} + require.Equal(t, h.Get(headerService), "aService") } diff --git a/_integration/amqp_admin.go b/integration/amqp_admin.go similarity index 71% rename from _integration/amqp_admin.go rename to integration/amqp_admin.go index fc87930..2a4a43f 100644 --- a/_integration/amqp_admin.go +++ b/integration/amqp_admin.go @@ -1,6 +1,9 @@ +//go:build integration +// +build integration + // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -27,33 +30,49 @@ import ( "fmt" "io" "net/http" + "reflect" + "regexp" + "strconv" + + "github.com/google/uuid" ) type amqpAdmin struct { httpClient *http.Client amqpAdminURL string - password string - username string - vhost string -} - -func AmqpAdmin(host string, port int, username, password, vhost string) *amqpAdmin { - return &amqpAdmin{ - httpClient: &http.Client{}, - amqpAdminURL: fmt.Sprintf("http://%s:%d/api", host, port), - username: username, - password: password, - vhost: vhost, + Password string + Username string + Host string + VHost string + Port int +} + +func FromURL(amqpURL string) (*amqpAdmin, error) { + amqpConnectionRegex := regexp.MustCompile(`(?:amqp://)?(?P.*):(?P.*?)@(?P.*?)(?:\:(?P\d*))?(?:/(?P.*))?$`) + if amqpConnectionRegex.MatchString(amqpURL) { + a := convertToAmqpConfig(mapValues(amqpConnectionRegex, amqpConnectionRegex.FindStringSubmatch(amqpURL))) + a.httpClient = &http.Client{} + a.amqpAdminURL = fmt.Sprintf("http://%s:%d/api", a.Host, 15672) + a.VHost = uuid.New().String() + return a, a.createVHost() } + + return nil, fmt.Errorf("invalid Amqp URL: %s", amqpURL) +} + +func (a *amqpAdmin) close() error { + err := a.deleteVHost() + a.httpClient.CloseIdleConnections() + return err } -func (a *amqpAdmin) CreateVHost() error { - _, err := a.request(http.MethodPut, fmt.Sprintf("/vhosts/%s", a.vhost), nil) +func (a *amqpAdmin) createVHost() error { + _, err := a.request(http.MethodPut, fmt.Sprintf("/vhosts/%s", a.VHost), nil) return err } -func (a *amqpAdmin) DeleteVHost() error { - _, err := a.request(http.MethodDelete, fmt.Sprintf("/vhosts/%s", a.vhost), nil) +func (a *amqpAdmin) deleteVHost() error { + _, err := a.request(http.MethodDelete, fmt.Sprintf("/vhosts/%s", a.VHost), nil) return err } @@ -69,10 +88,11 @@ func (a *amqpAdmin) GetExchange(name string) (*Exchange, error) { } func (a *amqpAdmin) GetExchanges(filterDefaults bool) ([]Exchange, error) { - resp, err := a.request(http.MethodGet, fmt.Sprintf("/exchanges/%s", a.vhost), nil) + resp, err := a.request(http.MethodGet, fmt.Sprintf("/exchanges/%s", a.VHost), nil) if err != nil { return nil, err } + defer resp.Body.Close() var exchanges []Exchange err = json.NewDecoder(resp.Body).Decode(&exchanges) if filterDefaults { @@ -99,20 +119,22 @@ func (a *amqpAdmin) GetQueue(name string) (*Queue, error) { } func (a *amqpAdmin) GetQueues() ([]Queue, error) { - resp, err := a.request(http.MethodGet, fmt.Sprintf("/queues/%s", a.vhost), nil) + resp, err := a.request(http.MethodGet, fmt.Sprintf("/queues/%s", a.VHost), nil) if err != nil { return nil, err } + defer resp.Body.Close() var queues []Queue err = json.NewDecoder(resp.Body).Decode(&queues) return queues, err } func (a *amqpAdmin) GetBindings(queueName string, filterDefault bool) ([]Binding, error) { - resp, err := a.request(http.MethodGet, fmt.Sprintf("/queues/%s/%s/bindings", a.vhost, queueName), nil) + resp, err := a.request(http.MethodGet, fmt.Sprintf("/queues/%s/%s/bindings", a.VHost, queueName), nil) if err != nil { return nil, err } + defer resp.Body.Close() var bindings []Binding err = json.NewDecoder(resp.Body).Decode(&bindings) if filterDefault { @@ -132,7 +154,7 @@ func (a *amqpAdmin) request(method, path string, body io.Reader) (*http.Response if err != nil { return nil, err } - req.SetBasicAuth(a.username, a.password) + req.SetBasicAuth(a.Username, a.Password) return a.httpClient.Do(req) } @@ -199,3 +221,33 @@ func (q Queue) Named() string { } var defaultExchanges = []string{"", "amq.direct", "amq.fanout", "amq.headers", "amq.match", "amq.rabbitmq.trace", "amq.topic"} + +func convertToAmqpConfig(mappedValues map[string]string) *amqpAdmin { + c := &amqpAdmin{} + r := reflect.ValueOf(c).Elem() + for i := 0; i < r.NumField(); i++ { + field := r.Type().Field(i) + value := mappedValues[field.Name] + if value == "" { + continue + } + v := reflect.ValueOf(value) + if field.Type.Kind() == reflect.Int { + atoi, _ := strconv.Atoi(value) + r.Field(i).Set(reflect.ValueOf(atoi)) + } else if field.Type.Kind() == reflect.String { + r.Field(i).Set(v) + } + } + return c +} + +func mapValues(amqpConnectionRegex *regexp.Regexp, groups []string) map[string]string { + mappedValues := make(map[string]string) + for i, name := range amqpConnectionRegex.SubexpNames() { + if i != 0 && name != "" { + mappedValues[name] = groups[i] + } + } + return mappedValues +} diff --git a/_integration/integration_test.go b/integration/integration_test.go similarity index 69% rename from _integration/integration_test.go rename to integration/integration_test.go index b3b237b..7a211b9 100644 --- a/_integration/integration_test.go +++ b/integration/integration_test.go @@ -1,6 +1,9 @@ +//go:build integration +// +build integration + // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -25,25 +28,23 @@ package _integration import ( "context" "fmt" + "os" "regexp" + "sync" "testing" "time" - "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "go.uber.org/goleak" . "github.com/sparetimecoders/goamqp" ) var ( - amqpUser = "user" - amqpPasswod = "password" - amqpHost = "localhost" - amqpPort = 5672 - amqpAdminPort = 15672 - amqpURL = fmt.Sprintf("amqp://%s:%s@%s:%d", amqpUser, amqpPasswod, amqpHost, amqpPort) serverServiceName = "server" + amqpURL = "amqp://user:password@localhost:5672" ) type IntegrationTestSuite struct { @@ -52,14 +53,19 @@ type IntegrationTestSuite struct { } func (suite *IntegrationTestSuite) SetupTest() { - suite.admin = AmqpAdmin(amqpHost, amqpAdminPort, amqpUser, amqpPasswod, uuid.New().String()) - err := suite.admin.CreateVHost() + if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { + amqpURL = urlFromEnv + } + var err error + suite.admin, err = FromURL(amqpURL) require.NoError(suite.T(), err) + require.NotNil(suite.T(), suite.admin) } func (suite *IntegrationTestSuite) TearDownTest() { - err := suite.admin.DeleteVHost() + err := suite.admin.close() require.NoError(suite.T(), err) + defer goleak.VerifyNone(suite.T()) } func TestIntegration(t *testing.T) { @@ -68,9 +74,9 @@ func TestIntegration(t *testing.T) { func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { conn := createConnection(suite, serverServiceName, - ServiceRequestConsumer("key", func(msg any, headers Headers) (response any, err error) { - return nil, nil - }, Incoming{}), + ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[any]) error { + return nil + }), ) defer conn.Close() @@ -82,35 +88,36 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { Internal: false, Name: "server.direct.exchange.request", Type: "direct", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, { AutoDelete: false, Durable: true, Internal: false, Name: "server.headers.exchange.response", Type: "headers", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, exchanges) queues, err := suite.admin.GetQueues() require.NoError(suite.T(), err) require.Equal(suite.T(), []Queue{{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "server.direct.exchange.request.queue", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, queues) bindings, err := suite.admin.GetBindings(queues[0].Name, true) require.NoError(suite.T(), err) require.Equal(suite.T(), []Binding{{ Source: "server.direct.exchange.request", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: queues[0].Name, DestinationType: "queue", RoutingKey: "key", @@ -120,38 +127,37 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { func (suite *IntegrationTestSuite) Test_RequestResponse() { closer := make(chan bool) - var serverReceived *Incoming + var serverReceived Incoming routingKey := "key" clientQuery := "test" server := createConnection(suite, serverServiceName, RequestResponseHandler( routingKey, - func(msg any, headers Headers) (any, error) { - serverReceived = msg.(*Incoming) + func(ctx context.Context, msg ConsumableEvent[Incoming]) (IncomingResponse, error) { + serverReceived = msg.Payload return IncomingResponse{Value: serverReceived.Query}, nil - }, Incoming{}), - WithTypeMapping(routingKey, Incoming{}), + }), ) defer server.Close() publish := NewPublisher() - var clientReceived *IncomingResponse + var clientReceived IncomingResponse client := createConnection(suite, "client", ServicePublisher(serverServiceName, publish), - ServiceResponseConsumer(serverServiceName, routingKey, func(msg any, headers Headers) (any, error) { - clientReceived = msg.(*IncomingResponse) + ServiceResponseConsumer(serverServiceName, routingKey, func(ctx context.Context, msg ConsumableEvent[IncomingResponse]) error { + clientReceived = msg.Payload closer <- true - return nil, nil - }, IncomingResponse{})) + return nil + })) defer client.Close() - err := publish.PublishWithContext(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), "routingKey", &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) <-closer - require.Equal(suite.T(), &IncomingResponse{Value: clientQuery}, clientReceived) - require.Equal(suite.T(), &Incoming{Query: clientQuery}, serverReceived) + require.Equal(suite.T(), IncomingResponse{Value: clientQuery}, clientReceived) + require.Equal(suite.T(), Incoming{Query: clientQuery}, serverReceived) // Verify exchanges exchanges, err := suite.admin.GetExchanges(true) @@ -162,14 +168,14 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { Internal: false, Name: "server.direct.exchange.request", Type: "direct", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, { AutoDelete: false, Durable: true, Internal: false, Name: "server.headers.exchange.response", Type: "headers", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, exchanges) // Verify queues and bindings @@ -180,14 +186,15 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "server.direct.exchange.request.queue", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, serverQueue) requestBinding, err := suite.admin.GetBindings("server.direct.exchange.request.queue", true) @@ -195,7 +202,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), []Binding{ { Source: "server.direct.exchange.request", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: "server.direct.exchange.request.queue", DestinationType: "queue", RoutingKey: routingKey, @@ -205,14 +212,15 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "server.headers.exchange.response.queue.client", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, clientQueue) responseBinding, err := suite.admin.GetBindings("server.headers.exchange.response.queue.client", true) @@ -220,7 +228,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), []Binding{ { Source: "server.headers.exchange.response", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: "server.headers.exchange.response.queue.client", DestinationType: "queue", RoutingKey: routingKey, @@ -238,33 +246,32 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { EventStreamPublisher(publish)) defer server.Close() - var client1Received *Incoming - var client2Received *Incoming + var client1Received Incoming + var client2Received Incoming client1 := createConnection(suite, "client1", - EventStreamConsumer(routingKey, func(msg any, headers Headers) (response any, err error) { - client1Received = msg.(*Incoming) + EventStreamConsumer(routingKey, func(ctx context.Context, msg ConsumableEvent[Incoming]) error { + client1Received = msg.Payload closer <- true - return nil, nil - }, Incoming{}), + return nil + }), ) defer client1.Close() client2 := createConnection(suite, "client2", - EventStreamConsumer(routingKey, func(msg any, headers Headers) (response any, err error) { - client2Received = msg.(*Incoming) + EventStreamConsumer(routingKey, func(ctx context.Context, msg ConsumableEvent[Incoming]) error { + client2Received = msg.Payload closer <- true - return nil, nil - }, Incoming{}), + return nil + }), ) defer client2.Close() - err := publish.PublishWithContext(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), routingKey, &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer - require.Equal(suite.T(), &Incoming{Query: clientQuery}, client1Received) - require.Equal(suite.T(), &Incoming{Query: clientQuery}, client2Received) + require.Equal(suite.T(), Incoming{Query: clientQuery}, client1Received) + require.Equal(suite.T(), Incoming{Query: clientQuery}, client2Received) // Verify exchanges exchanges, err := suite.admin.GetExchanges(true) @@ -275,7 +282,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { Internal: false, Name: "events.topic.exchange", Type: "topic", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, exchanges) // Verify queues and bindings @@ -284,14 +291,15 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "events.topic.exchange.queue.client1", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, client1Queue) client1Binding, err := suite.admin.GetBindings(client1Queue.Name, true) @@ -299,7 +307,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), []Binding{ { Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: "events.topic.exchange.queue.client1", DestinationType: "queue", RoutingKey: routingKey, @@ -312,14 +320,15 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "events.topic.exchange.queue.client2", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, client2Queue) client2Binding, err := suite.admin.GetBindings(client2Queue.Name, true) @@ -327,7 +336,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), []Binding{ { Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: "events.topic.exchange.queue.client2", DestinationType: "queue", RoutingKey: routingKey, @@ -344,36 +353,38 @@ func (suite *IntegrationTestSuite) Test_EventStream() { publish := NewPublisher() server := createConnection(suite, serverServiceName, EventStreamPublisher(publish), - WithTypeMapping(routingKey1, Incoming{}), - WithTypeMapping(routingKey2, IncomingResponse{}), ) defer server.Close() + mutex := sync.Mutex{} var received []any client1 := createConnection(suite, "client1", - TransientEventStreamConsumer(routingKey1, func(msg any, headers Headers) (response any, err error) { - received = append(received, msg) + TransientEventStreamConsumer(routingKey1, func(ctx context.Context, msg ConsumableEvent[Incoming]) error { + mutex.Lock() + defer mutex.Unlock() + received = append(received, msg.Payload) closer <- true - return nil, nil - }, Incoming{}), - EventStreamConsumer(routingKey2, func(msg any, headers Headers) (response any, err error) { - received = append(received, msg) + return nil + }), + EventStreamConsumer(routingKey2, func(ctx context.Context, msg ConsumableEvent[IncomingResponse]) error { + mutex.Lock() + defer mutex.Unlock() + received = append(received, msg.Payload) closer <- true - return nil, nil - }, IncomingResponse{}), + return nil + }), ) defer client1.Close() - err := publish.PublishWithContext(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), routingKey1, &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), routingKey2, &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer - require.Equal(suite.T(), &Incoming{Query: clientQuery}, received[0]) - require.Equal(suite.T(), &IncomingResponse{Value: clientQuery}, received[1]) + require.Equal(suite.T(), Incoming{Query: clientQuery}, received[0]) + require.Equal(suite.T(), IncomingResponse{Value: clientQuery}, received[1]) // Verify exchanges exchanges, err := suite.admin.GetExchanges(true) @@ -384,7 +395,7 @@ func (suite *IntegrationTestSuite) Test_EventStream() { Internal: false, Name: "events.topic.exchange", Type: "topic", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, exchanges) // Verify queues and bindings @@ -397,19 +408,20 @@ func (suite *IntegrationTestSuite) Test_EventStream() { if q.Name == "events.topic.exchange.queue.client1" { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: q.Name, - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, q) require.Equal(suite.T(), Binding{ Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: q.Name, DestinationType: "queue", RoutingKey: routingKey2, @@ -418,19 +430,20 @@ func (suite *IntegrationTestSuite) Test_EventStream() { } else { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: 1000, + XQueueType: amqp.QueueTypeQuorum, }, - AutoDelete: true, - Durable: false, + AutoDelete: false, + Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: q.Name, - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, q) require.Equal(suite.T(), Binding{ Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: q.Name, DestinationType: "queue", RoutingKey: routingKey1, @@ -444,7 +457,8 @@ func (suite *IntegrationTestSuite) Test_EventStream() { require.Equal(suite.T(), 2, len(queuesBeforeClose)) client1.Close() - + // Give rabbit some time to remove queues with expiration + time.Sleep(5 * time.Second) // Transient queues removed queuesAfterClose, err := suite.admin.GetQueues() require.NoError(suite.T(), err) @@ -461,9 +475,6 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { publish := NewPublisher() server := createConnection(suite, serverServiceName, EventStreamPublisher(publish), - WithTypeMapping("test.1", Incoming{}), - WithTypeMapping("1.2.test.2", Test{}), - WithTypeMapping(exactMatchRoutingKey, IncomingResponse{}), ) defer server.Close() @@ -471,37 +482,36 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { var wildcardReceiver []any var exactMatchReceiver []any client1 := createConnection(suite, "client1", - EventStreamConsumer(wildcardRoutingKey, func(msg any, headers Headers) (response any, err error) { - wildcardReceiver = append(wildcardReceiver, msg) + EventStreamConsumer(wildcardRoutingKey, func(ctx context.Context, msg ConsumableEvent[Incoming]) error { + wildcardReceiver = append(wildcardReceiver, msg.Payload) closer <- true - return nil, nil - }, Incoming{}), - EventStreamConsumer(wildcardStarRoutingKey, func(msg any, headers Headers) (response any, err error) { - wildcardStarReceiver = append(wildcardStarReceiver, msg) + return nil + }), + EventStreamConsumer(wildcardStarRoutingKey, func(ctx context.Context, msg ConsumableEvent[Test]) error { + wildcardStarReceiver = append(wildcardStarReceiver, msg.Payload) closer <- true - return nil, nil - }, Test{}), - EventStreamConsumer("testing", func(msg any, headers Headers) (response any, err error) { - exactMatchReceiver = append(exactMatchReceiver, msg) + return nil + }), + EventStreamConsumer("testing", func(ctx context.Context, msg ConsumableEvent[IncomingResponse]) error { + exactMatchReceiver = append(exactMatchReceiver, msg.Payload) closer <- true - return nil, nil - }, IncomingResponse{})) + return nil + })) defer client1.Close() - err := publish.PublishWithContext(context.Background(), &Test{Test: clientQuery}) + err := publish.Publish(context.Background(), "1.2.test.2", &Test{Test: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &Incoming{Query: clientQuery}) + err = publish.Publish(context.Background(), "test.1", &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), exactMatchRoutingKey, &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer <-closer - require.Equal(suite.T(), &Incoming{Query: clientQuery}, wildcardReceiver[0]) - require.Equal(suite.T(), &Test{Test: clientQuery}, wildcardStarReceiver[0]) - require.Equal(suite.T(), &IncomingResponse{Value: clientQuery}, exactMatchReceiver[0]) + require.Equal(suite.T(), Incoming{Query: clientQuery}, wildcardReceiver[0]) + require.Equal(suite.T(), Test{Test: clientQuery}, wildcardStarReceiver[0]) + require.Equal(suite.T(), IncomingResponse{Value: clientQuery}, exactMatchReceiver[0]) // Verify exchanges exchanges, err := suite.admin.GetExchanges(true) @@ -512,30 +522,32 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { Internal: false, Name: "events.topic.exchange", Type: "topic", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }}, exchanges) // Verify queues and bindings queuesBeforeClose, err := suite.admin.GetQueues() + require.NoError(suite.T(), err) require.Equal(suite.T(), 1, len(queuesBeforeClose)) q := queuesBeforeClose[0] bindings, err := suite.admin.GetBindings(q.Name, true) require.NoError(suite.T(), err) require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: "events.topic.exchange.queue.client1", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, }, q) require.ElementsMatch(suite.T(), bindings, []Binding{ { Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: q.Name, DestinationType: "queue", RoutingKey: wildcardStarRoutingKey, @@ -543,7 +555,7 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { }, { Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: q.Name, DestinationType: "queue", RoutingKey: wildcardRoutingKey, @@ -551,7 +563,7 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { }, { Source: "events.topic.exchange", - Vhost: suite.admin.vhost, + Vhost: suite.admin.VHost, Destination: q.Name, DestinationType: "queue", RoutingKey: exactMatchRoutingKey, @@ -571,26 +583,9 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { } func createConnection(suite *IntegrationTestSuite, serviceName string, opts ...Setup) *Connection { - conn, err := NewFromURL(serviceName, fmt.Sprintf("%s/%s", amqpURL, suite.admin.vhost)) + conn, err := NewFromURL(serviceName, fmt.Sprintf("%s/%s", amqpURL, suite.admin.VHost)) require.NoError(suite.T(), err) err = conn.Start(context.Background(), opts...) require.NoError(suite.T(), err) return conn } - -func forceClose(closer chan bool, seconds int64) { - time.Sleep(time.Duration(seconds) * time.Second) - closer <- true -} - -type Incoming struct { - Query string -} - -type Test struct { - Test string -} - -type IncomingResponse struct { - Value string -} diff --git a/logger.go b/integration/messages.go similarity index 82% rename from logger.go rename to integration/messages.go index 0e3487d..6e0e29f 100644 --- a/logger.go +++ b/integration/messages.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -20,10 +20,19 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package goamqp +//go:build integration +// +build integration -// errorLogf function called for error logs -type errorLog func(s string) +package _integration -// noOpLogger log function that does nothing -var noOpLogger = func(s string) {} +type Incoming struct { + Query string +} + +type Test struct { + Test string +} + +type IncomingResponse struct { + Value string +} diff --git a/integration/tracing_test.go b/integration/tracing_test.go new file mode 100644 index 0000000..e9a9a1d --- /dev/null +++ b/integration/tracing_test.go @@ -0,0 +1,65 @@ +//go:build integration +// +build integration + +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// MIT License + +package _integration + +import ( + "context" + + . "github.com/sparetimecoders/goamqp" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + tracesdk "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" +) + +func (suite *IntegrationTestSuite) Test_Tracing() { + closer := make(chan bool) + var actualTraceID trace.TraceID + publish := NewPublisher() + server := createConnection(suite, serverServiceName, + EventStreamPublisher(publish), + EventStreamConsumer("key", func(ctx context.Context, event ConsumableEvent[Test]) error { + actualTraceID = trace.SpanFromContext(ctx).SpanContext().TraceID() + closer <- true + return nil + }), + WithTypeMapping("key", Test{}), + ) + defer server.Close() + + // Setup tracing + otel.SetTracerProvider(tracesdk.NewTracerProvider()) + otel.SetTextMapPropagator(propagation.TraceContext{}) + publishingContext, _ := otel.Tracer("amqp").Start(context.Background(), "publish-test") + err := publish.Publish(publishingContext, Test{Test: "value"}) + require.NoError(suite.T(), err) + <-closer + + require.NoError(suite.T(), server.Close()) + require.Equal(suite.T(), trace.SpanFromContext(publishingContext).SpanContext().TraceID(), actualTraceID) +} diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go deleted file mode 100644 index 797acfb..0000000 --- a/internal/handlers/handlers.go +++ /dev/null @@ -1,104 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package handlers - -import ( - "fmt" -) - -// Handler is the handler for a certain routing key and queue -type Handler any - -// Handlers holds the handlers for a certain queue -type Handlers[T Handler] map[string]*T - -// Get returns the handler for the given queue and routing key that matches -func (h *Handlers[T]) Get(routingKey string) (*T, bool) { - for mappedRoutingKey, handler := range *h { - if match(mappedRoutingKey, routingKey) { - return handler, true - } - } - return nil, false -} - -// exists returns the already mapped routing key if it exists (matched by the matches function to support wildcards) -func (h *Handlers[T]) exists(routingKey string) (string, bool) { - for mappedRoutingKey := range *h { - if overlaps(routingKey, mappedRoutingKey) { - return mappedRoutingKey, true - } - } - return "", false -} - -func (h *Handlers[T]) add(routingKey string, handler *T) { - (*h)[routingKey] = handler -} - -// QueueHandlers holds all handlers for all queues -type QueueHandlers[T Handler] map[string]*Handlers[T] - -// Add a handler for the given queue and routing key -func (h *QueueHandlers[T]) Add(queueName, routingKey string, handler *T) error { - queueHandlers, ok := (*h)[queueName] - if !ok { - queueHandlers = &Handlers[T]{} - (*h)[queueName] = queueHandlers - } - - if mappedRoutingKey, exists := queueHandlers.exists(routingKey); exists { - return fmt.Errorf("routingkey %s overlaps %s for queue %s, consider using AddQueueNameSuffix", routingKey, mappedRoutingKey, queueName) - } - queueHandlers.add(routingKey, handler) - return nil -} - -type Queue[T Handler] struct { - Name string - Handlers *Handlers[T] -} - -// Queues returns all queue names for which we have added a handler -func (h *QueueHandlers[T]) Queues() []Queue[T] { - if h == nil { - return []Queue[T]{} - } - var res []Queue[T] - for q, h := range *h { - res = append(res, Queue[T]{Name: q, Handlers: h}) - } - return res -} - -// Handlers returns all the handlers for a given queue, keyed by the routing key -func (h *QueueHandlers[T]) Handlers(queueName string) *Handlers[T] { - if h == nil { - return &Handlers[T]{} - } - - if handlers, ok := (*h)[queueName]; ok { - return handlers - } - return &Handlers[T]{} -} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go deleted file mode 100644 index 26cf8b5..0000000 --- a/internal/handlers/handlers_test.go +++ /dev/null @@ -1,133 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package handlers - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -func Test_QueueHandlers(t *testing.T) { - qh := QueueHandlers[string]{} - queues := []string{"q1", "q2", "q3"} - keys := []string{"rk1", "rk2", "rk3"} - for _, q := range queues { - for _, rk := range keys { - require.NoError(t, qh.Add(q, rk, ptr(fmt.Sprintf("%s.%s", q, rk)))) - } - } - - require.Equal(t, len(queues), len(qh.Queues())) - for _, q := range qh.Queues() { - require.Equal(t, q.Handlers, qh.Handlers(q.Name)) - } - - handlerFor := func(q, r string) string { - h, exists := qh.Handlers(q).Get(r) - require.True(t, exists) - return *h - } - for _, q := range queues { - for _, rk := range keys { - require.Equal(t, fmt.Sprintf("%s.%s", q, rk), handlerFor(q, rk)) - } - } - - require.Equal(t, &Handlers[string]{}, qh.Handlers("missing")) - _, exists := qh.Handlers(queues[0]).Get("missing") - require.False(t, exists) -} - -func Test_QueueHandlersOverlap(t *testing.T) { - tests := []struct { - name string - key string - existing []string - wantErr assert.ErrorAssertionFunc - }{ - { - name: "empty", - key: "a", - existing: nil, - wantErr: assert.NoError, - }, - { - name: "dot split words", - key: "testing", - existing: []string{"test.#"}, - wantErr: assert.NoError, - }, - { - name: "overlap no wildcard", - key: "a", - existing: []string{"a"}, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "routingkey a overlaps a for queue q, consider using AddQueueNameSuffix") - }, - }, - { - name: "overlap * wildcard", - key: "user.updated", - existing: []string{"user.*"}, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "routingkey user.updated overlaps user.* for queue q, consider using AddQueueNameSuffix") - }, - }, - { - name: "overlap # wildcard", - key: "user.#", - existing: []string{"user.a.updated"}, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "routingkey user.# overlaps user.a.updated for queue q, consider using AddQueueNameSuffix") - }, - }, - { - name: "overlap other wildcard", - key: "user.#", - existing: []string{"user.a.*"}, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "routingkey user.# overlaps user.a.* for queue q, consider using AddQueueNameSuffix") - }, - }, - } - for _, tt := range tests { - queueName := "q" - ptrBool := ptr(true) - t.Run(tt.name, func(t *testing.T) { - h := QueueHandlers[bool]{} - for _, s := range tt.existing { - require.NoError(t, h.Add(queueName, s, ptrBool)) - } - if !tt.wantErr(t, h.Add(queueName, tt.key, ptrBool)) { - return - } - }) - } -} - -func ptr[T any](b T) *T { - return &b -} diff --git a/internal/handlers/matcher_test.go b/internal/handlers/matcher_test.go deleted file mode 100644 index acc86ec..0000000 --- a/internal/handlers/matcher_test.go +++ /dev/null @@ -1,135 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package handlers - -import ( - "fmt" - "testing" - - "github.com/stretchr/testify/assert" -) - -func Test_Overlaps(t *testing.T) { - tests := []struct { - name string - keys [2]string - wantOverlap bool - }{ - { - name: "no overlap without wildcard", - keys: [2]string{"a", "ab"}, - }, - { - name: "overlap same", - keys: [2]string{"a", "a"}, - wantOverlap: true, - }, - { - name: "overlap * wildcard", - keys: [2]string{"user.updated", "user.*"}, - wantOverlap: true, - }, - { - name: "overlap multiple wildcard", - keys: [2]string{"user.a.#", "user.#"}, - wantOverlap: true, - }, - { - name: "overlap # wildcard", - keys: [2]string{"user.#", "user.a.updated"}, - wantOverlap: true, - }, - { - name: "overlap other wildcard", - keys: [2]string{"user.#", "user.a.*"}, - wantOverlap: true, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if tt.wantOverlap { - assert.True(t, overlaps(tt.keys[0], tt.keys[1])) - } else { - assert.False(t, overlaps(tt.keys[0], tt.keys[1])) - } - }) - } -} - -func Test_Match(t *testing.T) { - tests := []struct { - name string - matches []string - misses []string - pattern string - }{ - { - name: "Errors in pattern", - misses: []string{""}, - pattern: "[[", - }, - { - name: "Match all", - matches: []string{"", "user", "user.1.2.3", "1.2.user."}, - pattern: "#", - }, - { - name: "Match one dot word", - matches: []string{"user.1", "user."}, - misses: []string{"user", "user.1.2"}, - pattern: "user.*", - }, - { - name: "Match one word", - matches: []string{"user", "user1"}, - misses: []string{"user.1", "user1.2.3.4"}, - pattern: "user*", - }, - { - name: "Match multiple words", - matches: []string{"user", "user1", "user1.2.3.4.5"}, - misses: []string{"use", "abc"}, - pattern: "user#", - }, - { - name: "Match multiple words and dots", - matches: []string{"users.1.test.2.abc.def", "user.1.test.2.abc", "user.1.2.2.4.5"}, - misses: []string{"use", "abc", "users.1.test.3.abc"}, - pattern: "user#.1.*.2.#", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - for _, key := range tt.matches { - if !match(tt.pattern, key) { - assert.Fail(t, fmt.Sprintf("%s should match %s", key, tt.pattern)) - } - } - for _, key := range tt.misses { - if match(tt.pattern, key) { - assert.Fail(t, fmt.Sprintf("%s should NOT match %s", key, tt.pattern)) - } - } - }) - } -} diff --git a/message_logger.go b/message_logger.go deleted file mode 100644 index d40070e..0000000 --- a/message_logger.go +++ /dev/null @@ -1,58 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package goamqp - -import ( - "bytes" - "encoding/json" - "fmt" - "reflect" -) - -// MessageLogger is a func that can be used to log in/outgoing messages for debugging purposes -type MessageLogger func(jsonContent []byte, eventType reflect.Type, routingKey string, outgoing bool) - -// NoOpMessageLogger is a MessageLogger that will do nothing -// This is the default implementation if the setup func UseMessageLogger is not used -func noOpMessageLogger() MessageLogger { - return func(jsonContent []byte, eventType reflect.Type, routingKey string, outgoing bool) { - } -} - -// StdOutMessageLogger is an example implementation of a MessageLogger that dumps messages with fmt.Printf -func StdOutMessageLogger() MessageLogger { - return func(jsonContent []byte, eventType reflect.Type, routingKey string, outgoing bool) { - var prettyJSON bytes.Buffer - err := json.Indent(&prettyJSON, jsonContent, "", "\t") - var prettyJSONString string - if err != nil { - prettyJSONString = string(jsonContent) - } else { - prettyJSONString = prettyJSON.String() - } - if outgoing { - fmt.Printf("Sending [%s] using routingkey: '%s' with content:\n%s\n", eventType, routingKey, prettyJSONString) - } - fmt.Printf("Received [%s] from routingkey: '%s' with content:\n%s\n", eventType, routingKey, prettyJSONString) - } -} diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..426de59 --- /dev/null +++ b/metrics.go @@ -0,0 +1,162 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + metricQueue = "queue" + metricExchange = "exchange" + metricResult = "result" + metricRoutingKey = "routing_key" +) + +var ( + eventReceivedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_received", + Help: "Count of AMQP events received", + }, []string{metricQueue, metricRoutingKey}, + ) + + eventWithoutHandlerCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_without_handler", + Help: "Count of AMQP events without a handler", + }, []string{metricQueue, metricRoutingKey}, + ) + + eventNotParsableCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_not_parsable", + Help: "Count of AMQP events that could not be parsed", + }, []string{metricQueue, metricRoutingKey}, + ) + + eventNackCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_nack", + Help: "Count of AMQP events that were not acknowledged", + }, []string{metricQueue, metricRoutingKey}, + ) + + eventAckCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_ack", + Help: "Count of AMQP events that were acknowledged", + }, []string{metricQueue, metricRoutingKey}, + ) + + eventProcessedDuration = prometheus.NewHistogramVec( + prometheus.HistogramOpts{ + Name: "amqp_events_processed_duration", + Help: "Milliseconds taken to process an event", + Buckets: []float64{100, 200, 500, 1000, 3000, 5000, 10000}, + }, []string{metricQueue, metricRoutingKey, metricResult}, + ) + + eventPublishSucceedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_publish_succeed", + Help: "Count of AMQP events that could be published successfully", + }, []string{metricExchange, metricRoutingKey}, + ) + + eventPublishFailedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_publish_failed", + Help: "Count of AMQP events that could not be published", + }, []string{metricExchange, metricRoutingKey}, + ) +) + +func eventReceived(queue string, routingKey string) { + eventReceivedCounter.WithLabelValues(queue, routingKey).Inc() +} + +func eventWithoutHandler(queue string, routingKey string) { + eventWithoutHandlerCounter.WithLabelValues(queue, routingKey).Inc() +} + +func eventNotParsable(queue string, routingKey string) { + eventNotParsableCounter.WithLabelValues(queue, routingKey).Inc() +} + +func eventNack(queue string, routingKey string, milliseconds int64) { + eventNackCounter.WithLabelValues(queue, routingKey).Inc() + + eventProcessedDuration. + WithLabelValues(queue, routingKey, "NACK"). + Observe(float64(milliseconds)) +} + +func eventAck(queue string, routingKey string, milliseconds int64) { + eventAckCounter.WithLabelValues(queue, routingKey).Inc() + + eventProcessedDuration. + WithLabelValues(queue, routingKey, "ACK"). + Observe(float64(milliseconds)) +} + +func eventPublishSucceed(exchange string, routingKey string) { + eventPublishSucceedCounter.WithLabelValues(exchange, routingKey).Inc() +} + +func eventPublishFailed(exchange string, routingKey string) { + eventPublishFailedCounter.WithLabelValues(exchange, routingKey).Inc() +} + +func InitMetrics(registerer prometheus.Registerer) error { + collectors := []prometheus.Collector{ + eventReceivedCounter, + eventAckCounter, + eventNackCounter, + eventWithoutHandlerCounter, + eventNotParsableCounter, + eventProcessedDuration, + eventPublishSucceedCounter, + eventPublishFailedCounter, + } + for _, collector := range collectors { + mv, ok := collector.(metricResetter) + if ok { + mv.Reset() + } + err := registerer.Register(collector) + if err != nil && !errors.As(err, &prometheus.AlreadyRegisteredError{}) { + return err + } + } + return nil +} + +// CounterVec, MetricVec and HistogramVec have a Reset func +// in order not to cast to each specific type, metricResetter can +// be used to just get access to the Reset func +type metricResetter interface { + Reset() +} diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..2033e68 --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,49 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" +) + +func Test_Metrics(t *testing.T) { + registry := prometheus.NewRegistry() + require.NoError(t, InitMetrics(registry)) + channel := NewMockAmqpChannel() + + err := publishMessage(context.Background(), channel, Message{true}, "key", "exchange", nil) + require.NoError(t, err) + metricFamilies, err := registry.Gather() + require.NoError(t, err) + var publishedSuccessfully float64 + for _, metric := range metricFamilies { + if *metric.Name == "amqp_events_publish_succeed" { + publishedSuccessfully = *metric.GetMetric()[0].GetCounter().Value + } + } + require.Equal(t, 1.0, publishedSuccessfully) +} diff --git a/mocks_test.go b/mocks_test.go index fa0b348..c2fac08 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -25,7 +25,6 @@ package goamqp import ( "context" "errors" - "reflect" amqp "github.com/rabbitmq/amqp091-go" ) @@ -138,6 +137,10 @@ type MockAmqpChannel struct { closeNotifier chan *amqp.Error } +func (m *MockAmqpChannel) Close() error { + return nil +} + func (m *MockAmqpChannel) Qos(prefetchCount, prefetchSize int, global bool) error { if m.qosFn == nil { return nil @@ -183,10 +186,6 @@ func (m *MockAmqpChannel) ExchangeDeclare(name, kind string, durable, autoDelete return nil } -func (m *MockAmqpChannel) Publish(exchange, key string, mandatory, immediate bool, msg amqp.Publishing) error { - return m.PublishWithContext(context.Background(), exchange, key, mandatory, immediate, msg) -} - func (m *MockAmqpChannel) PublishWithContext(ctx context.Context, exchange, key string, mandatory, immediate bool, msg amqp.Publishing) error { if key == "failed" { return errors.New("failed") @@ -238,14 +237,6 @@ func NewMockAmqpChannel() *MockAmqpChannel { } } -func NewMockAcknowledger() MockAcknowledger { - return MockAcknowledger{ - Acks: make(chan Ack, 2), - Nacks: make(chan Nack, 2), - Rejects: make(chan Reject, 2), - } -} - var ( _ amqpConnection = &MockAmqpConnection{} _ AmqpChannel = &MockAmqpChannel{} @@ -255,7 +246,6 @@ func mockConnection(channel *MockAmqpChannel) *Connection { c := newConnection("svc", amqp.URI{}) c.channel = channel c.connection = &MockAmqpConnection{} - c.messageLogger = noOpMessageLogger() return c } @@ -267,19 +257,3 @@ func (r badRand) Read(buf []byte) (int, error) { } return len(buf), nil } - -type MockLogger struct { - jsonContent []byte - eventType reflect.Type - routingKey string - outgoing bool -} - -func (m *MockLogger) logger() MessageLogger { - return func(jsonContent []byte, eventType reflect.Type, routingKey string, outgoing bool) { - m.jsonContent = jsonContent - m.eventType = eventType - m.routingKey = routingKey - m.outgoing = outgoing - } -} diff --git a/must.go b/must.go new file mode 100644 index 0000000..966b735 --- /dev/null +++ b/must.go @@ -0,0 +1,34 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +// Must is a helper that wraps a call to a function returning (*T, error) +// and panics if the error is non-nil. It is intended for use in variable +// initializations such as +// var c = goamqp.Must(goamqp.NewFromURL("service", "amqp://")) +func Must[T any](t *T, err error) *T { + if err != nil { + panic(err) + } + return t +} diff --git a/message_logger_test.go b/must_test.go similarity index 66% rename from message_logger_test.go rename to must_test.go index aaf5d85..0b54f4c 100644 --- a/message_logger_test.go +++ b/must_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -23,24 +23,19 @@ package goamqp import ( - "os" - "reflect" "testing" "github.com/stretchr/testify/require" ) -func TestStdoutLogger(t *testing.T) { - file, err := os.CreateTemp("", "") - require.NoError(t, err) - defer func() { _ = os.Remove(file.Name()) }() - logger := StdOutMessageLogger() - stdout := os.Stdout - os.Stdout = file - logger([]byte(`{"key":"value"}`), reflect.TypeOf(Connection{}), "key", false) - os.Stdout = stdout - require.NoError(t, file.Close()) - all, err := os.ReadFile(file.Name()) - require.NoError(t, err) - require.Equal(t, "Received [goamqp.Connection] from routingkey: 'key' with content:\n{\n\t\"key\": \"value\"\n}\n", string(all)) +func Test_Must(t *testing.T) { + conn := Must(NewFromURL("", "amqp://user:password@localhost:67333/a")) + require.NotNil(t, conn) + + defer func() { + if r := recover(); r == nil { + t.Errorf("The code did not panic") + } + }() + _ = Must(NewFromURL("", "invalid")) } diff --git a/naming.go b/naming.go index ff64bb0..2438142 100644 --- a/naming.go +++ b/naming.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -24,14 +24,16 @@ package goamqp import ( "fmt" + "strings" "github.com/google/uuid" + "github.com/rabbitmq/amqp091-go" ) const defaultEventExchangeName = "events" func topicExchangeName(svcName string) string { - return fmt.Sprintf("%s.%s.exchange", svcName, kindTopic) + return fmt.Sprintf("%s.%s.exchange", svcName, amqp091.ExchangeTopic) } func serviceEventQueueName(exchangeName, service string) string { @@ -61,3 +63,7 @@ func serviceResponseQueueName(targetService, serviceName string) string { func randomString() string { return uuid.New().String() } + +func trimExchangeFromQueue(queueName, exchangeName string) string { + return strings.TrimPrefix(strings.TrimPrefix(queueName, exchangeName), ".") +} diff --git a/naming_test.go b/naming_test.go index 0d2a2a8..48494ad 100644 --- a/naming_test.go +++ b/naming_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal diff --git a/notifications.go b/notifications.go new file mode 100644 index 0000000..44b150f --- /dev/null +++ b/notifications.go @@ -0,0 +1,70 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +type NotificationSource string + +const ( + NotificationSourceConsumer NotificationSource = "CONSUMER" +) + +type Notification struct { + DeliveryInfo DeliveryInfo + Duration int64 + Source NotificationSource +} +type ErrorNotification struct { + Error error + DeliveryInfo DeliveryInfo + Source NotificationSource + Duration int64 +} + +func notifyEventHandlerSucceed(ch chan<- Notification, info DeliveryInfo, took int64) { + if ch != nil { + select { + case ch <- Notification{ + DeliveryInfo: info, + Source: NotificationSourceConsumer, + Duration: took, + }: + default: + // Channel full, or not handling messages + } + } +} + +func notifyEventHandlerFailed(ch chan<- ErrorNotification, info DeliveryInfo, took int64, err error) { + if ch != nil { + select { + case ch <- ErrorNotification{ + Error: err, + DeliveryInfo: info, + Source: NotificationSourceConsumer, + Duration: took, + }: + default: + // Channel full, or not handling messages + } + } +} diff --git a/publish.go b/publish.go deleted file mode 100644 index fef1c2c..0000000 --- a/publish.go +++ /dev/null @@ -1,85 +0,0 @@ -// MIT License -// -// Copyright (c) 2019 sparetimecoders -// -// Permission is hereby granted, free of charge, to any person obtaining a copy -// of this software and associated documentation files (the "Software"), to deal -// in the Software without restriction, including without limitation the rights -// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -// copies of the Software, and to permit persons to whom the Software is -// furnished to do so, subject to the following conditions: -// -// The above copyright notice and this permission notice shall be included in all -// copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -// SOFTWARE. - -package goamqp - -import ( - "context" - "fmt" - "reflect" - - amqp "github.com/rabbitmq/amqp091-go" -) - -// Publisher is used to send messages -type Publisher struct { - connection *Connection - exchange string - defaultHeaders []Header -} - -// ErrNoRouteForMessageType when the published message cannot be routed. -var ErrNoRouteForMessageType = fmt.Errorf("no routingkey configured for message of type") - -// NewPublisher returns a publisher that can be used to send messages -func NewPublisher() *Publisher { - return &Publisher{} -} - -// Publish publishes a message to a given exchange -// Deprecated: Use PublishWithContext instead. -func (p *Publisher) Publish(msg any, headers ...Header) error { - return p.PublishWithContext(context.Background(), msg, headers...) -} - -func (p *Publisher) PublishWithContext(ctx context.Context, msg any, headers ...Header) error { - table := amqp.Table{} - for _, v := range p.defaultHeaders { - table[v.Key] = v.Value - } - for _, h := range headers { - if err := h.validateKey(); err != nil { - return err - } - table[h.Key] = h.Value - } - - t := reflect.TypeOf(msg) - key := t - if t.Kind() == reflect.Ptr { - key = t.Elem() - } - if key, ok := p.connection.typeToKey[key]; ok { - return p.connection.publishMessage(ctx, msg, key, p.exchange, table) - } - return fmt.Errorf("%w %s", ErrNoRouteForMessageType, t) -} - -func (p *Publisher) setDefaultHeaders(serviceName string, headers ...Header) error { - for _, h := range headers { - if err := h.validateKey(); err != nil { - return err - } - } - p.defaultHeaders = append(headers, Header{Key: headerService, Value: serviceName}) - return nil -} diff --git a/queue_binding_config.go b/queue_binding_config.go new file mode 100644 index 0000000..e81b3ee --- /dev/null +++ b/queue_binding_config.go @@ -0,0 +1,92 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "fmt" + "maps" + "reflect" + "runtime" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// ConsumerOptions is a setup function that takes a consumerConfig and provide custom changes to the +// configuration +type ConsumerOptions func(config *consumerConfig) error + +// consumerConfig is a wrapper around the actual amqp queue configuration +func newConsumerConfig(routingKey string, exchangeName string, queueName string, kind string, handler wrappedHandler, opts ...ConsumerOptions) (*consumerConfig, error) { + cfg := &consumerConfig{ + routingKey: routingKey, + handler: handler, + queueName: queueName, + exchangeName: exchangeName, + kind: kind, + queueHeaders: maps.Clone(defaultQueueOptions), + queueBindingHeaders: make(amqp.Table), + } + + for _, f := range opts { + if err := f(cfg); err != nil { + return nil, fmt.Errorf("queuebinding setup function <%s> failed, %v", getQueueBindingConfigSetupFuncName(f), err) + } + } + return cfg, nil +} + +type consumerConfig struct { + routingKey string + handler wrappedHandler + queueName string + exchangeName string + kind string + queueHeaders amqp.Table + queueBindingHeaders amqp.Table +} + +// AddQueueNameSuffix appends the provided suffix to the queue name +// Useful when multiple queueConsumers are needed for a routing key in the same service +func AddQueueNameSuffix(suffix string) ConsumerOptions { + return func(config *consumerConfig) error { + if suffix == "" { + return ErrEmptySuffix + } + config.queueName = fmt.Sprintf("%s-%s", config.queueName, suffix) + return nil + } +} + +// DisableSingleActiveConsumer will define the queue as non exclusive and set the x-single-active-consumer header to false +// https://www.rabbitmq.com/docs/consumers#exclusivity +func DisableSingleActiveConsumer() ConsumerOptions { + return func(config *consumerConfig) error { + config.queueHeaders[amqp.SingleActiveConsumerArg] = false + return nil + } +} + +// getQueueBindingConfigSetupFuncName returns the name of the ConsumerOptions function +func getQueueBindingConfigSetupFuncName(f ConsumerOptions) string { + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} diff --git a/queue_binding_config_test.go b/queue_binding_config_test.go new file mode 100644 index 0000000..6a4b6cd --- /dev/null +++ b/queue_binding_config_test.go @@ -0,0 +1,39 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEmptyQueueNameSuffix(t *testing.T) { + require.EqualError(t, AddQueueNameSuffix("")(&consumerConfig{}), ErrEmptySuffix.Error()) +} + +func TestQueueNameSuffix(t *testing.T) { + cfg := &consumerConfig{queueName: "queue"} + require.NoError(t, AddQueueNameSuffix("suffix")(cfg)) + require.Equal(t, "queue-suffix", cfg.queueName) +} diff --git a/request_response.go b/request_response.go new file mode 100644 index 0000000..c1ea027 --- /dev/null +++ b/request_response.go @@ -0,0 +1,70 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "errors" + "fmt" +) + +// RequestResponseHandler is a convenience func to set up ServiceRequestConsumer and combines it with +// PublishServiceResponse +func RequestResponseHandler[T any, R any](routingKey string, handler RequestResponseEventHandler[T, R]) Setup { + return func(c *Connection) error { + responseHandlerWrapper := responseWrapper(handler, routingKey, func(ctx context.Context, targetService, routingKey string, msg R) error { + return c.PublishServiceResponse(ctx, targetService, routingKey, msg) + }) + return ServiceRequestConsumer[T](routingKey, responseHandlerWrapper)(c) + } +} + +func responseWrapper[T, R any](handler RequestResponseEventHandler[T, R], routingKey string, publisher ServiceResponsePublisher[R]) EventHandler[T] { + return func(ctx context.Context, event ConsumableEvent[T]) (err error) { + resp, err := handler(ctx, event) + if err != nil { + return fmt.Errorf("failed to process message, %w", err) + } + service, err := sendingService(event.DeliveryInfo) + if err != nil { + return fmt.Errorf("failed to extract service name, %w", err) + } + err = publisher(ctx, service, routingKey, resp) + if err != nil { + return fmt.Errorf("failed to publish response, %w", err) + } + return nil + } +} + +// sendingService returns the name of the service that produced the message +// Can be used to send a handlerResponse, see PublishServiceResponse +func sendingService(di DeliveryInfo) (string, error) { + if h, exist := di.Headers[headerService]; exist { + switch v := h.(type) { + case string: + return v, nil + } + } + return "", errors.New("no service found") +} diff --git a/request_response_test.go b/request_response_test.go new file mode 100644 index 0000000..b26682f --- /dev/null +++ b/request_response_test.go @@ -0,0 +1,80 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "reflect" + "runtime" + "testing" + + "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +func Test_RequestResponseHandler(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + type response struct { + Value string + } + expectedResponse := response{ + Value: "response", + } + err := RequestResponseHandler("key", func(ctx context.Context, msg ConsumableEvent[Message]) (response, error) { + return expectedResponse, nil + })(conn) + require.NoError(t, err) + + require.Equal(t, 2, len(channel.ExchangeDeclarations)) + require.Equal(t, ExchangeDeclaration{name: "svc.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: nil}, channel.ExchangeDeclarations[0]) + require.Equal(t, ExchangeDeclaration{name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: nil}, channel.ExchangeDeclarations[1]) + + require.Equal(t, 1, len(channel.QueueDeclarations)) + require.Equal(t, QueueDeclaration{name: "svc.direct.exchange.request.queue", noWait: false, autoDelete: false, durable: true, exclusive: false, args: defaultQueueOptions}, channel.QueueDeclarations[0]) + + require.Equal(t, 1, len(channel.BindingDeclarations)) + require.Equal(t, BindingDeclaration{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: amqp091.Table{}}, channel.BindingDeclarations[0]) + + require.Len(t, (*conn).queueConsumers.consumers, 1) + handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") + require.Equal(t, "github.com/sparetimecoders/goamqp.newWrappedHandler[...].func2", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + missing, exists := conn.queueConsumers.get("miggins", "key") + require.Nil(t, missing) + require.False(t, exists) + + msg, _ := json.Marshal(Message{Ok: true}) + err = handler(context.TODO(), unmarshalEvent{ + Metadata: Metadata{}, + DeliveryInfo: DeliveryInfo{ + Headers: Headers{headerService: ""}, + }, + Payload: msg, + }) + require.NoError(t, err) + published := <-channel.Published + var resp response + require.NoError(t, json.Unmarshal(published.msg.Body, &resp)) + require.Equal(t, expectedResponse, resp) +} diff --git a/internal/handlers/matcher.go b/routingkey_handlers.go similarity index 63% rename from internal/handlers/matcher.go rename to routingkey_handlers.go index 8f2090a..e13a980 100644 --- a/internal/handlers/matcher.go +++ b/routingkey_handlers.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2025 sparetimecoders // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package handlers +package goamqp import ( "fmt" @@ -28,6 +28,33 @@ import ( "strings" ) +// routingKeyHandler holds the mapping from routing key to a specific handler +type routingKeyHandler map[string]wrappedHandler + +// get returns the handler for the given routing key that matches +func (h *routingKeyHandler) get(routingKey string) (wrappedHandler, bool) { + for mappedRoutingKey, handler := range *h { + if match(mappedRoutingKey, routingKey) { + return handler, true + } + } + return nil, false +} + +// exists returns the already mapped routing key if it exists (matched by the overlaps function to support wildcards) +func (h *routingKeyHandler) exists(routingKey string) (string, bool) { + for mappedRoutingKey := range *h { + if overlaps(routingKey, mappedRoutingKey) { + return mappedRoutingKey, true + } + } + return "", false +} + +func (h *routingKeyHandler) add(routingKey string, handler wrappedHandler) { + (*h)[routingKey] = handler +} + // overlaps checks if two AMQP binding patterns overlap func overlaps(p1, p2 string) bool { if p1 == p2 { @@ -54,6 +81,6 @@ func match(pattern string, routingKey string) bool { // user.* => user\.[^.]* // user.# => user\..* func fixRegex(s string) string { - replace := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, ".", "\\."), "*", "[^.]*"), "#", ".*") + replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) return fmt.Sprintf("^%s$", replace) } diff --git a/routingkey_handlers_test.go b/routingkey_handlers_test.go new file mode 100644 index 0000000..52f4fe2 --- /dev/null +++ b/routingkey_handlers_test.go @@ -0,0 +1,91 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func Test_RoutingKeyHandlers(t *testing.T) { + type mapping struct { + key string + handler wrappedHandler + } + + tests := []struct { + name string + mappings []mapping + expectedMatches []string + invalid bool + }{ + { + name: "exists", + mappings: []mapping{{ + key: "key", + }}, + expectedMatches: []string{"key"}, + }, + { + name: "wildcard in key", + mappings: []mapping{{ + key: "key.a", + }}, + expectedMatches: []string{"key.#"}, + }, + { + name: "wildcard in mapping", + mappings: []mapping{{ + key: "key.#", + }}, + expectedMatches: []string{"key.a"}, + }, + { + name: "invalid wildcard", + mappings: []mapping{{ + key: `[`, + }}, + invalid: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rkh := make(routingKeyHandler) + for _, m := range tt.mappings { + rkh.add(m.key, m.handler) + } + for _, m := range tt.expectedMatches { + matchedRoutingKey, ok := rkh.exists(m) + require.Equal(t, ok, !tt.invalid) + _, ok = rkh.get(matchedRoutingKey) + require.Equal(t, ok, !tt.invalid) + } + + _, ok := rkh.exists("missing") + require.False(t, ok) + _, ok = rkh.get("missing") + require.False(t, ok) + }) + } +} diff --git a/setup.go b/setup.go new file mode 100644 index 0000000..29a24e0 --- /dev/null +++ b/setup.go @@ -0,0 +1,111 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "errors" + "fmt" + "reflect" + "runtime" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// Setup is a setup function that takes a Connection and use it to set up AMQP +// An example is to create exchanges and queues +type Setup func(conn *Connection) error + +// WithPrefetchLimit configures the number of messages to prefetch from the server. +// To get round-robin behavior between queueConsumers consuming from the same queue on +// different connections, set the prefetch count to 1, and the next available +// message on the server will be delivered to the next available consumer. +// If your consumer work time is reasonably consistent and not much greater +// than two times your network round trip time, you will see significant +// throughput improvements starting with a prefetch count of 2 or slightly +// greater, as described by benchmarks on RabbitMQ. +// +// http://www.rabbitmq.com/blog/2012/04/25/rabbitmq-performance-measurements-part-2/ +func WithPrefetchLimit(limit int) Setup { + return func(conn *Connection) error { + return conn.channel.Qos(limit, 0, false) + } +} + +// WithNotificationChannel specifies a go channel to receive messages +// such as connection event published, consumed, etc. +func WithNotificationChannel(notificationCh chan<- Notification) Setup { + return func(conn *Connection) error { + conn.notificationCh = notificationCh + return nil + } +} + +// WithErrorChannel specifies a go channel to receive messages such as event failed +func WithErrorChannel(errorCh chan<- ErrorNotification) Setup { + return func(conn *Connection) error { + conn.errorCh = errorCh + return nil + } +} + +var spanNameFn = func(info DeliveryInfo) string { + return fmt.Sprintf("%s#%s", trimExchangeFromQueue(info.Queue, info.Exchange), info.RoutingKey) +} + +// WithSpanNameFn specifies a function that will get called when a new span is created +// By default the spanNameFn will be used +func WithSpanNameFn(f func(DeliveryInfo) string) Setup { + return func(conn *Connection) error { + conn.spanNameFn = f + return nil + } +} + +// CloseListener receives a callback when the AMQP Channel gets closed +func CloseListener(e chan error) Setup { + return func(c *Connection) error { + temp := make(chan *amqp.Error) + go func() { + for { + if ev := <-temp; ev != nil { + e <- errors.New(ev.Error()) + } + } + }() + c.channel.NotifyClose(temp) + return nil + } +} + +// PublishNotify see amqp.Channel.Confirm +func PublishNotify(confirm chan amqp.Confirmation) Setup { + return func(c *Connection) error { + c.channel.NotifyPublish(confirm) + return c.channel.Confirm(false) + } +} + +// getSetupFuncName returns the name of the Setup function +func getSetupFuncName(f Setup) string { + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} diff --git a/setup_consumer.go b/setup_consumer.go new file mode 100644 index 0000000..b04507a --- /dev/null +++ b/setup_consumer.go @@ -0,0 +1,171 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + + amqp "github.com/rabbitmq/amqp091-go" +) + +type ( + // TypeMapper is a function that maps a routing key to a specific type + TypeMapper func(routingKey string) (reflect.Type, bool) + // EventHandler is a function that is used to handle events of a specific type. + // If processing fails, an error should be returned and the message will be re-queued + EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) error + // RequestResponseEventHandler is a function that is used to handle events of a specific + // type and return a response with RequestResponseHandler. + // If processing fails, an error should be returned and the message will be re-queued + RequestResponseEventHandler[T any, R any] func(ctx context.Context, event ConsumableEvent[T]) (R, error) +) + +// TypeMappingHandler wraps a EventHandler[any] func and uses the TypeMapper to determine how to unwrap the ConsumableEvent +// payload +func TypeMappingHandler(handler EventHandler[any], routingKeyToType TypeMapper) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { + if routingKeyToType == nil { + return fmt.Errorf("mapper is nil") + } + + typ, exists := routingKeyToType(event.DeliveryInfo.RoutingKey) + if !exists { + return ErrNoMessageTypeForRouteKey + } + payload := reflect.New(typ.Elem()).Interface() + if err := json.Unmarshal(event.Payload.(json.RawMessage), &payload); err != nil { + return fmt.Errorf("%v: %w", err, ErrParseJSON) + } + event.Payload = payload + return handler(ctx, event) + } +} + +// EventStreamConsumer sets up ap a durable, persistent event stream consumer. +// For a transient queue, use the TransientEventStreamConsumer function instead. +func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + return StreamConsumer(defaultEventExchangeName, routingKey, handler, opts...) +} + +// ServiceResponseConsumer is a specialization of EventStreamConsumer +// It sets up ap a durable, persistent consumer (exchange->queue) for responses from targetService +func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + return func(c *Connection) error { + opts = append(opts, func(config *consumerConfig) error { + config.queueBindingHeaders[headerService] = c.serviceName + return nil + }) + + config, err := newConsumerConfig(routingKey, + serviceResponseExchangeName(targetService), + serviceResponseQueueName(targetService, c.serviceName), + amqp.ExchangeHeaders, + newWrappedHandler(handler), + opts...) + if err != nil { + return err + } + + return c.messageHandlerBindQueueToExchange(config) + } +} + +// ServiceRequestConsumer is a specialization of EventStreamConsumer +// It sets up ap a durable, persistent consumer (exchange->queue) for message to the service owning the Connection +func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + return func(c *Connection) error { + resExchangeName := serviceResponseExchangeName(c.serviceName) + if err := exchangeDeclare(c.channel, resExchangeName, amqp.ExchangeHeaders); err != nil { + return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) + } + + config, err := newConsumerConfig(routingKey, + serviceRequestExchangeName(c.serviceName), + serviceRequestQueueName(c.serviceName), + amqp.ExchangeDirect, + newWrappedHandler(handler), + opts...) + if err != nil { + return err + } + for _, f := range opts { + if err := f(config); err != nil { + return fmt.Errorf("queuebinding setup function <%s> failed, %v", getQueueBindingConfigSetupFuncName(f), err) + } + } + return c.messageHandlerBindQueueToExchange(config) + } +} + +// StreamConsumer sets up ap a durable, persistent event stream consumer. +func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + config, err := newConsumerConfig(routingKey, + exchangeName, + serviceEventQueueName(exchangeName, c.serviceName), + amqp.ExchangeTopic, + newWrappedHandler(handler), + opts...) + if err != nil { + return err + } + + return c.messageHandlerBindQueueToExchange(config) + } +} + +// TransientEventStreamConsumer sets up an event stream consumer that will clean up resources when the +// connection is closed. +// For a durable queue, use the EventStreamConsumer function instead. +func TransientEventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler, opts...) +} + +// TransientStreamConsumer sets up an event stream consumer that will clean up resources when the +// connection is closed. +// For a durable queue, use the StreamConsumer function instead. +func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) + opts = append(opts, func(config *consumerConfig) error { + config.queueHeaders[amqp.QueueTTLArg] = 1000 + return nil + }) + config, err := newConsumerConfig(routingKey, + exchangeName, + queueName, + amqp.ExchangeTopic, + newWrappedHandler(handler), + opts...) + if err != nil { + return err + } + + return c.messageHandlerBindQueueToExchange(config) + } +} diff --git a/setup_consumer_test.go b/setup_consumer_test.go new file mode 100644 index 0000000..2d3da1d --- /dev/null +++ b/setup_consumer_test.go @@ -0,0 +1,293 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "maps" + "reflect" + "testing" + + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_Consumer_Setups(t *testing.T) { + // Needed for transient stream tests + uuid.SetRand(badRand{}) + + tests := []struct { + name string + opts []Setup + expectedError string + expectedExchanges []ExchangeDeclaration + expectedQueues []QueueDeclaration + expectedBindings []BindingDeclaration + expectedConsumer []Consumer + }{ + { + name: "EventStreamConsumer", + opts: []Setup{EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })}, + expectedExchanges: []ExchangeDeclaration{{name: "events.topic.exchange", noWait: false, internal: false, autoDelete: false, durable: true, kind: "topic", args: nil}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc", noWait: false, autoDelete: false, durable: true, exclusive: false, args: defaultQueueOptions}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc", noWait: false, exchange: "events.topic.exchange", key: "key", args: amqp.Table{}}}, + expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + }, + { + name: "EventStreamConsumer with suffix", + opts: []Setup{EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + }, AddQueueNameSuffix("suffix"))}, + expectedExchanges: []ExchangeDeclaration{{name: "events.topic.exchange", noWait: false, internal: false, autoDelete: false, durable: true, kind: "topic", args: nil}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-suffix", noWait: false, autoDelete: false, durable: true, exclusive: false, args: defaultQueueOptions}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-suffix", noWait: false, exchange: "events.topic.exchange", key: "key", args: amqp.Table{}}}, + expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-suffix", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + }, + { + name: "EventStreamConsumer with empty suffix - fails", + opts: []Setup{EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + }, AddQueueNameSuffix(""))}, + expectedError: "failed, empty queue suffix not allowed", + }, + { + name: "ServiceRequestConsumer", + opts: []Setup{ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })}, + expectedExchanges: []ExchangeDeclaration{ + {name: "svc.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: nil}, + {name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: nil}, + }, + expectedQueues: []QueueDeclaration{{name: "svc.direct.exchange.request.queue", noWait: false, autoDelete: false, durable: true, exclusive: false, args: defaultQueueOptions}}, + expectedBindings: []BindingDeclaration{{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: amqp.Table{}}}, + expectedConsumer: []Consumer{{queue: "svc.direct.exchange.request.queue", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + }, + { + name: "ServiceResponseConsumer", + opts: []Setup{ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })}, + expectedExchanges: []ExchangeDeclaration{{name: "targetService.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: nil}}, + expectedQueues: []QueueDeclaration{{name: "targetService.headers.exchange.response.queue.svc", noWait: false, autoDelete: false, durable: true, exclusive: false, args: defaultQueueOptions}}, + expectedBindings: []BindingDeclaration{{queue: "targetService.headers.exchange.response.queue.svc", noWait: false, exchange: "targetService.headers.exchange.response", key: "key", args: amqp.Table{headerService: "svc"}}}, + expectedConsumer: []Consumer{{queue: "targetService.headers.exchange.response.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + }, + { + name: "TransientEventStreamConsumer", + opts: []Setup{TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") + })}, + expectedExchanges: []ExchangeDeclaration{{name: "events.topic.exchange", kind: "topic", durable: true, autoDelete: false, internal: false, noWait: false, args: nil}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: amqp.Table{}}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: true, autoDelete: false, exclusive: false, noWait: false, args: func() map[string]any { + clone := maps.Clone(defaultQueueOptions) + clone[amqp.QueueTTLArg] = 1000 + return clone + }()}}, + expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + }, + { + name: "routing key already exists", + opts: []Setup{ + TransientEventStreamConsumer("root.key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") + }), TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") + }), + }, + expectedExchanges: []ExchangeDeclaration{{name: "events.topic.exchange", kind: "topic", durable: true, autoDelete: false, internal: false, noWait: false, args: nil}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "root.key", exchange: "events.topic.exchange", noWait: false, args: amqp.Table{}}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: true, autoDelete: false, exclusive: false, noWait: false, args: func() map[string]any { + clone := maps.Clone(defaultQueueOptions) + clone[amqp.QueueTTLArg] = 1000 + return clone + }()}}, + expectedError: "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + err := conn.Start(context.Background(), tt.opts...) + + if tt.expectedConsumer != nil { + require.Equal(t, tt.expectedConsumer, channel.Consumers) + } else { + require.Len(t, channel.Consumers, 0) + } + if tt.expectedExchanges != nil { + require.Equal(t, tt.expectedExchanges, channel.ExchangeDeclarations) + } else { + require.Len(t, channel.ExchangeDeclarations, 0) + } + if tt.expectedQueues != nil { + require.Equal(t, tt.expectedQueues, channel.QueueDeclarations) + } else { + require.Len(t, channel.QueueDeclarations, 0) + } + if tt.expectedBindings != nil { + require.Equal(t, tt.expectedBindings, channel.BindingDeclarations) + } else { + require.Len(t, channel.BindingDeclarations, 0) + } + if tt.expectedError != "" { + require.ErrorContains(t, err, tt.expectedError) + } else { + require.NoError(t, err) + } + }) + } +} + +func Test_TypeMappingHandler(t *testing.T) { + type fields struct { + mapper TypeMapper + } + type args struct { + handler func(t *testing.T) EventHandler[any] + msg json.RawMessage + key string + } + tests := []struct { + name string + fields fields + args args + wantErr assert.ErrorAssertionFunc + }{ + { + name: "no mapped type, ignored", + fields: fields{ + mapper: func(ctx context.Context, routingKey string) (reflect.Type, bool) { + return nil, false + }, + }, + args: args{ + msg: []byte(`{"a":true}`), + key: "unknown", + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { + t.Fail() + return nil + } + }, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorIs(t, err, ErrNoMessageTypeForRouteKey) + }, + }, + { + name: "parse error", + fields: fields{ + mapper: func(ctx context.Context, routingKey string) (reflect.Type, bool) { + if routingKey == "known" { + return reflect.TypeOf(&TestMessage{}), true + } + return nil, false + }, + }, + args: args{ + msg: []byte(`{"a:}`), + key: "known", + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { + return nil + } + }, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorContains(t, err, "unexpected end of JSON input") + }, + }, + { + name: "handler error", + fields: fields{ + mapper: func(ctx context.Context, routingKey string) (reflect.Type, bool) { + if routingKey == "known" { + return reflect.TypeOf(&TestMessage{}), true + } + return nil, false + }, + }, + args: args{ + msg: []byte(`{"a":true}`), + key: "known", + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { + _, ok := event.Payload.(*TestMessage) + assert.True(t, ok) + return fmt.Errorf("handler-error") + } + }, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.EqualError(t, err, "handler-error") + }, + }, + { + name: "success", + fields: fields{ + mapper: func(ctx context.Context, routingKey string) (reflect.Type, bool) { + if routingKey == "known" { + return reflect.TypeOf(&TestMessage{}), true + } + return nil, false + }, + }, + args: args{ + msg: []byte(`{"a":true}`), + key: "known", + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { + _, ok := event.Payload.(*TestMessage) + assert.True(t, ok) + return nil + } + }, + }, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.TODO() + + handler := TypeMappingHandler(tt.args.handler(t), tt.fields.mapper) + err := handler(ctx, ConsumableEvent[any]{ + Payload: tt.args.msg, + DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, + }) + if !tt.wantErr(t, err) { + return + } + }) + } +} diff --git a/setup_publisher.go b/setup_publisher.go new file mode 100644 index 0000000..ed5aadb --- /dev/null +++ b/setup_publisher.go @@ -0,0 +1,143 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "fmt" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// Publisher is used to send messages +type Publisher struct { + channel AmqpChannel + exchange string + defaultHeaders []Header +} + +var ( + ErrNoMessageTypeForRouteKey = fmt.Errorf("no message type for routingkey configured") +) + +// NewPublisher returns a publisher that can be used to send messages +func NewPublisher() *Publisher { + return &Publisher{} +} + +// PublishWithContext wraps Publish to ease migration to new version of goamqp +// Deprecated: use Publish directly +func (p *Publisher) PublishWithContext(ctx context.Context, routingkKey string, msg any, headers ...Header) error { + return p.Publish(ctx, routingkKey, msg, headers...) +} + +// Publish tries to publish msg to AMQP. +func (p *Publisher) Publish(ctx context.Context, routingkKey string, msg any, headers ...Header) error { + table := amqp.Table{} + for _, v := range p.defaultHeaders { + table[v.Key] = v.Value + } + for _, h := range headers { + if err := h.validateKey(); err != nil { + return err + } + table[h.Key] = h.Value + } + + return publishMessage(ctx, p.channel, msg, routingkKey, p.exchange, table) +} + +// EventStreamPublisher sets up an event stream publisher +func EventStreamPublisher(publisher *Publisher) Setup { + return StreamPublisher(defaultEventExchangeName, publisher) +} + +// StreamPublisher sets up an event stream publisher +func StreamPublisher(exchange string, publisher *Publisher) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + if err := exchangeDeclare(c.channel, exchangeName, amqp.ExchangeTopic); err != nil { + return fmt.Errorf("failed to declare exchange %s, %w", exchangeName, err) + } + return publisher.setup(c.channel, c.serviceName, exchangeName) + } +} + +// QueuePublisher sets up a publisher that will send events to a specific queue instead of using the exchange, +// so called Sender-Selected distribution +// https://www.rabbitmq.com/sender-selected.html#:~:text=The%20RabbitMQ%20broker%20treats%20the,key%20if%20they%20are%20present. +func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { + return func(c *Connection) error { + return publisher.setup(c.channel, c.serviceName, "", Header{Key: "CC", Value: []any{destinationQueueName}}) + } +} + +// ServicePublisher sets up ap a publisher, that sends messages to the targetService +func ServicePublisher(targetService string, publisher *Publisher) Setup { + exchangeName := serviceRequestExchangeName(targetService) + return func(c *Connection) error { + if err := exchangeDeclare(c.channel, exchangeName, amqp.ExchangeDirect); err != nil { + return err + } + return publisher.setup(c.channel, c.serviceName, exchangeName) + } +} + +func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, headers ...Header) error { + for _, h := range headers { + if err := h.validateKey(); err != nil { + return err + } + } + p.defaultHeaders = append(headers, Header{Key: headerService, Value: serviceName}) + p.channel = channel + p.exchange = exchange + return nil +} + +func publishMessage(ctx context.Context, channel AmqpChannel, msg any, routingKey, exchangeName string, headers amqp.Table) error { + jsonBytes, err := json.Marshal(msg) + if err != nil { + return err + } + + publishing := amqp.Publishing{ + Body: jsonBytes, + ContentType: contentType, + DeliveryMode: 2, + Headers: injectToHeaders(ctx, headers), + } + err = channel.PublishWithContext(ctx, exchangeName, + routingKey, + false, + false, + publishing, + ) + if err != nil { + eventPublishFailed(exchangeName, routingKey) + return err + } + eventPublishSucceed(exchangeName, routingKey) + return nil +} diff --git a/setup_publisher_test.go b/setup_publisher_test.go new file mode 100644 index 0000000..99b1f0c --- /dev/null +++ b/setup_publisher_test.go @@ -0,0 +1,203 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "encoding/json" + "maps" + "slices" + "testing" + + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +func Test_Publisher_Setups(t *testing.T) { + // Needed for transient stream tests + uuid.SetRand(badRand{}) + + tests := []struct { + name string + opts func(p *Publisher) []Setup + messages map[string]any + expectedError string + expectedExchanges []ExchangeDeclaration + expectedQueues []QueueDeclaration + expectedBindings []BindingDeclaration + expectedPublished []*Publish + headers []Header + }{ + { + name: "EventStreamConsumer", + opts: func(p *Publisher) []Setup { + return []Setup{QueuePublisher(p, "destQueue")} + }, + messages: map[string]any{"key": TestMessage{"test", true}}, + headers: []Header{{"x-header", "header"}}, + expectedPublished: []*Publish{{ + exchange: "", + key: "key", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"CC": []interface{}{"destQueue"}, "service": "svc", "x-header": "header"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }}, + }, + { + name: "EventStreamPublisher", + opts: func(p *Publisher) []Setup { + return []Setup{EventStreamPublisher(p)} + }, + messages: map[string]any{"key": TestMessage{"test", true}}, + headers: []Header{{"x-header", "header"}}, + expectedExchanges: []ExchangeDeclaration{{name: topicExchangeName(defaultEventExchangeName), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeTopic, args: nil}}, + expectedPublished: []*Publish{{ + exchange: topicExchangeName(defaultEventExchangeName), + key: "key", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"service": "svc", "x-header": "header"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }}, + }, + { + name: "ServicePublisher", + opts: func(p *Publisher) []Setup { + return []Setup{ServicePublisher("svc", p)} + }, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, + messages: map[string]any{"key": TestMessage{"test", true}}, + expectedPublished: []*Publish{{ + exchange: serviceRequestExchangeName("svc"), + key: "key", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"service": "svc"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }}, + }, + { + name: "ServicePublisher - multiple", + opts: func(p *Publisher) []Setup { + return []Setup{ServicePublisher("svc", p)} + }, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, + messages: map[string]any{ + "key1": TestMessage{"test", true}, + "key2": TestMessage2{"test", false}, + }, + expectedPublished: []*Publish{ + { + exchange: serviceRequestExchangeName("svc"), + key: "key1", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"service": "svc"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }, { + exchange: serviceRequestExchangeName("svc"), + key: "key2", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"service": "svc"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + p := NewPublisher() + ctx := context.TODO() + startErr := conn.Start(context.Background(), tt.opts(p)...) + require.NoError(t, startErr) + if tt.expectedExchanges != nil { + require.Equal(t, tt.expectedExchanges, channel.ExchangeDeclarations) + } else { + require.Len(t, channel.ExchangeDeclarations, 0) + } + if tt.expectedQueues != nil { + require.Equal(t, tt.expectedQueues, channel.QueueDeclarations) + } else { + require.Len(t, channel.QueueDeclarations, 0) + } + if tt.expectedBindings != nil { + require.Equal(t, tt.expectedBindings, channel.BindingDeclarations) + } else { + require.Len(t, channel.BindingDeclarations, 0) + } + + keys := slices.Collect(maps.Keys(tt.messages)) + slices.Sort(keys) + for i := 0; i < len(keys); i++ { + key := keys[i] + msg := tt.messages[key] + err := p.Publish(ctx, key, msg, tt.headers...) + if tt.expectedError != "" { + require.ErrorContains(t, err, tt.expectedError) + continue + } else { + require.NoError(t, err) + } + if tt.expectedPublished[i] != nil { + body, err := json.Marshal(msg) + require.NoError(t, err) + tt.expectedPublished[i].msg.Body = body + require.Equal(t, *tt.expectedPublished[i], <-channel.Published) + } else if tt.expectedError == "" { + require.Fail(t, "nothing published, and no error wanted!") + } + i++ + } + }) + } +} + +func Test_InvalidHeader(t *testing.T) { + err := (&Publisher{}).setup(nil, "", "", Header{Key: "", Value: ""}) + require.ErrorIs(t, err, ErrEmptyHeaderKey) +} diff --git a/setup_test.go b/setup_test.go new file mode 100644 index 0000000..34d29a2 --- /dev/null +++ b/setup_test.go @@ -0,0 +1,115 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + "errors" + "testing" + + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +func Test_CloseListener(t *testing.T) { + listener := make(chan error) + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + err := CloseListener(listener)(conn) + require.NoError(t, err) + require.Equal(t, true, channel.NotifyCloseCalled) + // nil is ignored + channel.ForceClose(nil) + channel.ForceClose(&amqp.Error{Code: 123, Reason: "Close reason"}) + err = <-listener + require.EqualError(t, err, "Exception (123) Reason: \"Close reason\"") +} + +func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + p := NewPublisher() + + e := errors.New("failed to create exchange") + channel.ExchangeDeclarationError = &e + err := EventStreamPublisher(p)(conn) + require.Error(t, err) + require.EqualError(t, err, "failed to declare exchange events.topic.exchange, failed to create exchange") +} + +func Test_ServicePublisher_ExchangeDeclareFail(t *testing.T) { + e := errors.New("failed") + channel := NewMockAmqpChannel() + channel.ExchangeDeclarationError = &e + conn := mockConnection(channel) + + p := NewPublisher() + + err := ServicePublisher("svc", p)(conn) + require.Error(t, err) + require.EqualError(t, err, e.Error()) +} + +func Test_PublishNotify(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + notifier := make(chan amqp.Confirmation) + err := PublishNotify(notifier)(conn) + require.NoError(t, err) + require.Equal(t, ¬ifier, channel.Confirms) + require.Equal(t, true, channel.ConfirmCalled) +} + +func Test_Start_WithPrefetchLimit_Resets_Qos(t *testing.T) { + mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} + mockChannel := &MockAmqpChannel{ + qosFn: func(cc int) func(prefetchCount, prefetchSize int, global bool) error { + return func(prefetchCount, prefetchSize int, global bool) error { + defer func() { + cc++ + }() + if cc == 0 { + require.Equal(t, 20, prefetchCount) + } else { + require.Equal(t, 1, prefetchCount) + } + return nil + } + }(0), + } + conn := &Connection{ + serviceName: "test", + connection: mockAmqpConnection, + channel: mockChannel, + queueConsumers: &queueConsumers{}, + } + notifications := make(chan<- Notification) + errors := make(chan<- ErrorNotification) + err := conn.Start(context.Background(), + WithPrefetchLimit(1), + WithNotificationChannel(notifications), + WithErrorChannel(errors), + ) + require.NoError(t, err) + require.Equal(t, notifications, conn.notificationCh) +} diff --git a/tracing.go b/tracing.go new file mode 100644 index 0000000..852d31e --- /dev/null +++ b/tracing.go @@ -0,0 +1,54 @@ +// MIT License +// +// Copyright (c) 2025 sparetimecoders +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +package goamqp + +import ( + "context" + + amqp "github.com/rabbitmq/amqp091-go" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" +) + +// inject the span context to amqp table +func injectToHeaders(ctx context.Context, headers amqp.Table) amqp.Table { + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, carrier) + for k, v := range carrier { + headers[k] = v + } + return headers +} + +// extract the amqp table to a span context +func extractToContext(headers amqp.Table) context.Context { + carrier := propagation.MapCarrier{} + for k, v := range headers { + value, ok := v.(string) + if ok { + carrier[k] = value + } + } + + return otel.GetTextMapPropagator().Extract(context.Background(), carrier) +}