From 691b7eb6c29cea19eb79bbc1847c687a792e6e74 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 2 Feb 2024 14:09:29 +0100 Subject: [PATCH 01/50] chore: wip --- LICENSE | 2 +- _integration/amqp_admin.go | 2 +- _integration/integration_test.go | 2 +- connection.go | 178 ++++++++---------- connection_options.go | 90 +++------ connection_options_test.go | 22 +-- connection_test.go | 6 +- .../handlers/matcher.go => consumableEvent.go | 59 +++--- doc.go | 2 +- docs/message_processing.md | 1 - docs/naming.md | 1 - example_test.go | 2 +- examples/event-stream/example_test.go | 50 ++--- examples/request-response/example_test.go | 23 ++- go.mod | 5 + go.sum | 20 ++ handler.go | 162 ++++++++++++++++ headers.go | 2 +- headers_test.go | 2 +- message_logger.go | 58 ------ message_logger_test.go | 46 ----- mocks_test.go | 2 +- naming.go | 2 +- naming_test.go | 2 +- publish.go | 10 +- publishableEvent.go | 83 ++++++++ logger.go => tracing.go | 37 +++- 27 files changed, 511 insertions(+), 360 deletions(-) rename internal/handlers/matcher.go => consumableEvent.go (52%) create mode 100644 handler.go delete mode 100644 message_logger.go create mode 100644 publishableEvent.go rename logger.go => tracing.go (59%) diff --git a/LICENSE b/LICENSE index 2dbdee3..09907a6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 sparetimecoders +Copyright (c) 2024 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/_integration/amqp_admin.go b/_integration/amqp_admin.go index fc87930..e32c4bd 100644 --- a/_integration/amqp_admin.go +++ b/_integration/amqp_admin.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/_integration/integration_test.go b/_integration/integration_test.go index b3b237b..6f7afae 100644 --- a/_integration/integration_test.go +++ b/_integration/integration_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/connection.go b/connection.go index e4dac2f..6401662 100644 --- a/connection.go +++ b/connection.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -35,39 +35,28 @@ import ( "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 + started bool + serviceName string + amqpUri amqp.URI + connection amqpConnection + // TODO One channel per queue/consumer 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 + queueHandlers *QueueHandlers + typeToKey map[reflect.Type]string + keyToType map[string]reflect.Type } // ServiceResponsePublisher represents the function that is called to publish a response -type ServiceResponsePublisher func(ctx context.Context, targetService, routingKey string, msg any) error +type ServiceResponsePublisher[T any] func(ctx context.Context, targetService, routingKey string, msg T) error // QueueBindingConfig is a wrapper around the actual amqp queue configuration type QueueBindingConfig struct { routingKey string - handler HandlerFunc - eventType eventType + handler wrappedHandler queueName string exchangeName string kind kind @@ -137,8 +126,7 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { if c.started { return ErrAlreadyStarted } - c.messageLogger = noOpMessageLogger() - c.errorLog = noOpLogger + // TODO Multiple channels if c.channel == nil { err := c.connectToAmqpURL() if err != nil { @@ -146,6 +134,7 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { } } + // TODO Qos from opt (per queue?) if err := c.channel.Qos(20, 0, true); err != nil { return err } @@ -172,23 +161,21 @@ 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) +func (c *Connection) TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { + return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) (any, error) { + routingKey := event.DeliveryInfo.RoutingKey typ, exists := c.keyToType[routingKey] if !exists { return nil, nil } - body := []byte(*msg.(*json.RawMessage)) - message, err := c.parseMessage(body, typ) - if err != nil { + message := reflect.New(typ).Interface() + if err := json.Unmarshal(event.Payload, &message); err != nil { return nil, err + } + if resp, err := handler(ctx, message); err == nil { + return resp, nil } else { - if resp, err := handler(message, headers); err == nil { - return resp, nil - } else { - return nil, err - } + return nil, err } } } @@ -198,8 +185,6 @@ 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 @@ -231,7 +216,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,20 +228,19 @@ 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) +func responseWrapper[T, R any](handler EventHandler[T], routingKey string, publisher ServiceResponsePublisher[R]) EventHandler[T] { + return func(ctx context.Context, event ConsumableEvent[T]) (response any, err error) { + resp, err := handler(ctx, event) if err != nil { return nil, errors.Wrap(err, "failed to process message") } if resp != nil { - service, err := sendingService(headers) + service, err := sendingService(event.DeliveryInfo) 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) + // TODO Handle response with type R + err = publisher(ctx, service, routingKey, resp.(R)) if err != nil { return nil, errors.Wrapf(err, "failed to publish response") } @@ -306,7 +290,7 @@ func amqpConfig(serviceName string) amqp.Config { 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 } @@ -325,6 +309,7 @@ func (c *Connection) connectToAmqpURL() error { if err != nil { return err } + // TODO Multiple channels ch, err := conn.Channel() if err != nil { return err @@ -349,34 +334,8 @@ func (c *Connection) exchangeDeclare(channel AmqpChannel, name string, kind kind ) } -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) addHandler(queueName, routingKey string, handler wrappedHandler) error { + return c.queueHandlers.Add(queueName, routingKey, handler) } func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, exchangeName string, headers amqp.Table) error { @@ -384,7 +343,6 @@ func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, ex if err != nil { return err } - c.messageLogger(jsonBytes, reflect.TypeOf(msg), routingKey, true) publishing := amqp.Publishing{ Body: jsonBytes, @@ -401,23 +359,56 @@ func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, ex ) } -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 getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { + deliveryInfo := DeliveryInfo{ + Queue: queueName, + Exchange: delivery.Exchange, + RoutingKey: delivery.RoutingKey, + Headers: Headers(delivery.Headers), + } + return deliveryInfo +} + +func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue Queue) { + for delivery := range deliveries { + //startTime := time.Now() + deliveryInfo := getDeliveryInfo(queue.Name, delivery) + //EventReceived(c.queueName, deliveryInfo.RoutingKey) + + // Establish which handler is invoked + handler, ok := queue.Handlers.Get(deliveryInfo.RoutingKey) + if !ok { + // TODO Handle missing handler? + _ = delivery.Reject(false) + + //if c.options.defaultHandler == nil { + // _ = delivery.Nack(false, false) + // EventWithoutHandler(c.queueName, deliveryInfo.RoutingKey) + // continue + //} + //handler = c.options.defaultHandler + } + + uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} + tracingCtx := extractToContext(delivery.Headers) + // TODO Handle response + if _, err := handler(tracingCtx, uevt); err != nil { + // elapsed := time.Since(startTime).Milliseconds() + // notifyEventHandlerFailed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed, err) + _ = delivery.Nack(false, false) + // EventNack(c.queueName, deliveryInfo.RoutingKey, elapsed) + continue } + + //elapsed := time.Since(startTime).Milliseconds() + //notifyEventHandlerSucceed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed) + _ = delivery.Ack(false) + //EventAck(c.queueName, deliveryInfo.RoutingKey, elapsed) } } 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 := c.addHandler(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err } @@ -454,7 +445,7 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { return &Connection{ serviceName: serviceName, amqpUri: uri, - queueHandlers: &handlers.QueueHandlers[messageHandlerInvoker]{}, + queueHandlers: &QueueHandlers{}, keyToType: make(map[string]reflect.Type), typeToKey: make(map[reflect.Type]string), } @@ -466,7 +457,7 @@ func (c *Connection) setup() error { if err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", queue.Name, err) } - go c.divertToMessageHandlers(consumer, queue.Handlers) + go c.divertToMessageHandlers(consumer, queue) } return nil } @@ -480,11 +471,6 @@ func getEventType(eventType any) (eventType, error) { 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() @@ -497,8 +483,8 @@ func getQueueBindingConfigSetupFuncName(f QueueBindingConfigSetup) string { // 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 { +func sendingService(di DeliveryInfo) (string, error) { + if h, exist := di.Headers[headerService]; exist { switch v := h.(type) { case string: return v, nil diff --git a/connection_options.go b/connection_options.go index 094b1c6..0cf9a90 100644 --- a/connection_options.go +++ b/connection_options.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2023 sparetimecoders +// Copyright (c) 2024 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 @@ -50,6 +50,22 @@ func WithTypeMapping(routingKey string, msgType any) Setup { } } +func WithHandler[T any](routingKey string, handler EventHandler[T]) Setup { + exchangeName := topicExchangeName(defaultEventExchangeName) + return func(c *Connection) error { + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: serviceEventQueueName(exchangeName, c.serviceName), + exchangeName: exchangeName, + kind: kindTopic, + headers: amqp.Table{}, + } + + return c.messageHandlerBindQueueToExchange(config) + } +} + // CloseListener receives a callback when the AMQP Channel gets closed func CloseListener(e chan error) Setup { return func(c *Connection) error { @@ -66,28 +82,6 @@ func CloseListener(e chan error) Setup { } } -// 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 @@ -107,14 +101,14 @@ func WithPrefetchLimit(limit int) Setup { // 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) +func TransientEventStreamConsumer[T any](routingKey string, handler EventHandler[T]) Setup { + return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) } // 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...) +func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts ...QueueBindingConfigSetup) Setup { + return StreamConsumer(defaultEventExchangeName, routingKey, handler, opts...) } // EventStreamPublisher sets up an event stream publisher @@ -125,18 +119,11 @@ func EventStreamPublisher(publisher *Publisher) Setup { // 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 { +func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHandler[T]) 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 { + if err := c.addHandler(queueName, routingKey, newWrappedHandler(handler)); err != nil { return err } @@ -151,17 +138,12 @@ func TransientStreamConsumer(exchange, routingKey string, handler HandlerFunc, e } // StreamConsumer sets up ap a durable, persistent event stream consumer. -func StreamConsumer(exchange, routingKey string, handler HandlerFunc, eventType any, opts ...QueueBindingConfigSetup) Setup { +func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], 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, + handler: newWrappedHandler(handler), queueName: serviceEventQueueName(exchangeName, c.serviceName), exchangeName: exchangeName, kind: kindTopic, @@ -211,16 +193,11 @@ func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { // 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 { +func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T], 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, + handler: newWrappedHandler(handler), queueName: serviceResponseQueueName(targetService, c.serviceName), exchangeName: serviceResponseExchangeName(targetService), kind: kindHeaders, @@ -233,12 +210,8 @@ func ServiceResponseConsumer(targetService, routingKey string, handler HandlerFu // 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 { +func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T]) 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) @@ -246,8 +219,7 @@ func ServiceRequestConsumer(routingKey string, handler HandlerFunc, eventType an config := &QueueBindingConfig{ routingKey: routingKey, - handler: handler, - eventType: eventTyp, + handler: newWrappedHandler(handler), queueName: serviceRequestQueueName(c.serviceName), exchangeName: serviceRequestExchangeName(c.serviceName), kind: kindDirect, @@ -276,10 +248,10 @@ func ServicePublisher(targetService string, publisher *Publisher) Setup { // RequestResponseHandler is a convenience func to set up ServiceRequestConsumer and combines it with // PublishServiceResponse -func RequestResponseHandler(routingKey string, handler HandlerFunc, eventType any) Setup { +func RequestResponseHandler[T any](routingKey string, handler EventHandler[T]) Setup { return func(c *Connection) error { responseHandlerWrapper := responseWrapper(handler, routingKey, c.PublishServiceResponse) - return ServiceRequestConsumer(routingKey, responseHandlerWrapper, eventType)(c) + return ServiceRequestConsumer[T](routingKey, responseHandlerWrapper)(c) } } diff --git a/connection_options_test.go b/connection_options_test.go index d0ccf94..969eaf4 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2023 sparetimecoders +// Copyright (c) 2024 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 @@ -77,13 +77,13 @@ func Test_EventStreamPublisher_Ok(t *testing.T) { require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) + err = p.Publish(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"}) + err = p.Publish(context.Background(), TestMessage{Msg: "test"}, Header{"x-header", "header"}) require.NoError(t, err) published = <-channel.Published @@ -106,13 +106,13 @@ func Test_QueuePublisher_Ok(t *testing.T) { require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) + err = p.Publish(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"}) + err = p.Publish(context.Background(), TestMessage{Msg: "test"}, Header{"x-header", "header"}) require.NoError(t, err) published = <-channel.Published @@ -147,7 +147,7 @@ func Test_UseMessageLogger(t *testing.T) { ) require.NotNil(t, conn.messageLogger) - err := p.PublishWithContext(context.Background(), TestMessage{"test", true}) + err := p.Publish(context.Background(), TestMessage{"test", true}) require.NoError(t, err) <-channel.Published @@ -336,7 +336,7 @@ func Test_ServicePublisher_Ok(t *testing.T) { require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) + err = p.Publish(context.Background(), TestMessage{"test", true}) require.NoError(t, err) published := <-channel.Published require.Equal(t, "key", published.key) @@ -359,11 +359,11 @@ func Test_ServicePublisher_Multiple(t *testing.T) { require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) - err = p.PublishWithContext(context.Background(), TestMessage{"test", true}) + err = p.Publish(context.Background(), TestMessage{"test", true}) require.NoError(t, err) - err = p.PublishWithContext(context.Background(), TestMessage2{Msg: "msg"}) + err = p.Publish(context.Background(), TestMessage2{Msg: "msg"}) require.NoError(t, err) - err = p.PublishWithContext(context.Background(), TestMessage{"test2", false}) + err = p.Publish(context.Background(), TestMessage{"test2", false}) require.NoError(t, err) published := <-channel.Published require.Equal(t, "key", published.key) @@ -390,7 +390,7 @@ func Test_ServicePublisher_NoMatchingRoute(t *testing.T) { require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) - err = p.PublishWithContext(context.Background(), &TestMessage{Msg: "test"}) + err = p.Publish(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") } diff --git a/connection_test.go b/connection_test.go index 877eecd..79d7a16 100644 --- a/connection_test.go +++ b/connection_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -34,12 +34,10 @@ import ( 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()) + require.Equal(t, "_unknown_", version()) } func Test_getEventType(t *testing.T) { diff --git a/internal/handlers/matcher.go b/consumableEvent.go similarity index 52% rename from internal/handlers/matcher.go rename to consumableEvent.go index 8f2090a..baadded 100644 --- a/internal/handlers/matcher.go +++ b/consumableEvent.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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,40 +20,41 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package handlers +package goamqp import ( - "fmt" - "regexp" - "strings" + "encoding/json" + "time" ) -// overlaps checks if two AMQP binding patterns overlap -func overlaps(p1, p2 string) bool { - if p1 == p2 { - return true - } else if match(p1, p2) { - return true - } else if match(p2, p1) { - return true - } - return false +// Metadata holds the metadata of an event. +type Metadata struct { + ID string `json:"id"` + CorrelationID string `json:"correlationId"` + Timestamp time.Time `json:"timestamp"` } -// match returns true if the AMQP binding pattern is matching the routing key -func match(pattern string, routingKey string) bool { - b, err := regexp.MatchString(fixRegex(pattern), routingKey) - if err != nil { - return false - } - return b +// DeliveryInfo holds information of original queue, exchange and routing keys. +type DeliveryInfo struct { + Queue string + Exchange string + RoutingKey string + Headers Headers } -// fixRegex converts the AMQP binding key syntax to regular expression -// For example: -// user.* => user\.[^.]* -// user.# => user\..* -func fixRegex(s string) string { - replace := strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(s, ".", "\\."), "*", "[^.]*"), "#", ".*") - return fmt.Sprintf("^%s$", replace) +// 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/doc.go b/doc.go index 64a7201..895e206 100644 --- a/doc.go +++ b/doc.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 diff --git a/docs/message_processing.md b/docs/message_processing.md index ff436bb..ce2d118 100644 --- a/docs/message_processing.md +++ b/docs/message_processing.md @@ -44,4 +44,3 @@ If anything but `nil` is returned from `HandlerFunc` the message will be rejecte be processed again). If goamqp fails to unmarshal the JSON content in the message, the message will be rejected and **not** requeued 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..175d243 100644 --- a/example_test.go +++ b/example_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index cfcb468..5e1d101 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -23,14 +23,16 @@ import ( "context" "fmt" "os" + "testing" "time" . "github.com/sparetimecoders/goamqp" ) -var amqpURL = "amqp://user:password@localhost:5672/test" +// var amqpURL = "amqp://user:password@localhost:5672/test" +var amqpURL = "amqp://user:password@goodfeed-control-plane.orb.local:5672/test" -func Example_event_stream() { +func Test_A(t *testing.T) { ctx := context.Background() if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { amqpURL = urlFromEnv @@ -39,6 +41,8 @@ func Example_event_stream() { orderPublisher := NewPublisher() err := orderServiceConnection.Start(ctx, EventStreamPublisher(orderPublisher), + WithTypeMapping("Order.Created", OrderCreated{}), + WithTypeMapping("Order.Updated", OrderUpdated{}), ) checkError(err) @@ -50,14 +54,12 @@ func Example_event_stream() { err = statService.Start(ctx) checkError(err) - err = orderPublisher.PublishWithContext(context.Background(), OrderCreated{Id: "id"}) + err = orderPublisher.Publish(context.Background(), OrderCreated{Id: "id"}) checkError(err) - err = orderPublisher.PublishWithContext(context.Background(), OrderUpdated{Id: "id"}) + err = orderPublisher.Publish(context.Background(), OrderUpdated{Id: "id", Data: "data"}) checkError(err) - time.Sleep(2 * time.Second) _ = orderServiceConnection.Close() - _ = shippingService.Stop() _ = statService.Stop() } @@ -73,19 +75,18 @@ func (s *StatService) Stop() error { func (s *StatService) Start(ctx context.Context) error { s.connection = Must(NewFromURL("stat-service", amqpURL)) return s.connection.Start(ctx, - EventStreamConsumer("Order.Created", s.handleOrderEvent, OrderCreated{}), + WithHandler("Order.Created", s.handleOrderCreated), + WithHandler("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") - } +func (s *StatService) handleOrderUpdated(ctx context.Context, msg ConsumableEvent[OrderUpdated]) (response any, err error) { + fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) + return nil, nil +} +func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) (response any, err error) { + // Just to make sure the Output is correct in the example... + fmt.Printf("Created order, %s\n", msg.Payload.Id) return nil, nil } @@ -100,13 +101,17 @@ func (s *ShippingService) Stop() error { func (s *ShippingService) Start(ctx context.Context) error { s.connection = Must(NewFromURL("shipping-service", amqpURL)) + return s.connection.Start(ctx, - EventStreamConsumer("Order.Created", s.handleOrderEvent, OrderCreated{}), - EventStreamConsumer("Order.Updated", s.handleOrderEvent, OrderUpdated{}), - ) + WithTypeMapping("Order.Created", OrderCreated{}), + WithTypeMapping("Order.Updated", OrderUpdated{}), + WithHandler("#", s.connection.TypeMappingHandler(func(ctx context.Context, event any) (any, error) { + return s.handleOrderEvent(ctx, event) + }), + )) } -func (s *ShippingService) handleOrderEvent(msg any, headers Headers) (response any, err error) { +func (s *ShippingService) handleOrderEvent(ctx context.Context, msg any) (response any, err error) { switch msg.(type) { case *OrderCreated: fmt.Println("Order created") @@ -128,7 +133,8 @@ type OrderCreated struct { Id string } type OrderUpdated struct { - Id string + Id string + Data string } type ShippingUpdated struct { diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index 86326cb..db66a9d 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -1,4 +1,4 @@ -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -38,7 +38,7 @@ func Example_request_response() { routingKey := "key" serviceConnection := Must(NewFromURL("service", amqpURL)) err := serviceConnection.Start(ctx, - RequestResponseHandler(routingKey, handleRequest, Request{}), + RequestResponseHandler(routingKey, handleRequest), ) checkError(err) @@ -46,17 +46,22 @@ func Example_request_response() { publisher := NewPublisher() err = clientConnection.Start(ctx, + WithTypeMapping(routingKey, Request{}), ServicePublisher("service", publisher), ServiceResponseConsumer("service", routingKey, handleResponse, Response{}), ) checkError(err) - err = publisher.PublishWithContext(context.Background(), Request{Data: "test"}) + err = publisher.Publish(context.Background(), 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,16 +70,14 @@ 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 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) +func handleResponse(ctx context.Context, m ConsumableEvent[Response]) (any, error) { + fmt.Printf("Got response, %v\n", m.Payload.Data) return nil, nil } diff --git a/go.mod b/go.mod index f8231fb..6d420f6 100644 --- a/go.mod +++ b/go.mod @@ -7,10 +7,15 @@ require ( github.com/pkg/errors v0.9.1 github.com/rabbitmq/amqp091-go v1.10.0 github.com/stretchr/testify v1.10.0 + go.opentelemetry.io/otel v1.22.0 ) require ( 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/pmezard/go-difflib v1.0.0 // indirect + go.opentelemetry.io/otel/metric v1.22.0 // indirect + go.opentelemetry.io/otel/trace v1.22.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index bd7681e..dfd3a66 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,12 @@ 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/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= @@ -10,9 +17,22 @@ github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzuk github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= 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/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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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..30b0399 --- /dev/null +++ b/handler.go @@ -0,0 +1,162 @@ +// MIT License +// +// Copyright (c) 2024 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" + "regexp" + "strings" +) + +type Handler func(ctx context.Context, event any) (any, error) + +// EventHandler is the type definition for a function that is used to handle events of a specific type. +// TODO 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 EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) (any, error) + +// Handlers holds the handlers for a certain queue +type Handlers map[string]wrappedHandler + +// Get returns the handler for the given queue and routing key that matches +func (h *Handlers) 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 matches function to support wildcards) +func (h *Handlers) exists(routingKey string) (string, bool) { + for mappedRoutingKey := range *h { + if overlaps(routingKey, mappedRoutingKey) { + return mappedRoutingKey, true + } + } + return "", false +} + +func (h *Handlers) add(routingKey string, handler wrappedHandler) { + (*h)[routingKey] = handler +} + +// QueueHandlers holds all handlers for all queues +type QueueHandlers map[string]*Handlers + +// Add a handler for the given queue and routing key +func (h *QueueHandlers) Add(queueName, routingKey string, handler wrappedHandler) error { + queueHandlers, ok := (*h)[queueName] + if !ok { + queueHandlers = &Handlers{} + (*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 struct { + Name string + Handlers *Handlers +} + +// Queues returns all queue names for which we have added a handler +func (h *QueueHandlers) Queues() []Queue { + if h == nil { + return []Queue{} + } + var res []Queue + for q, h := range *h { + res = append(res, Queue{Name: q, Handlers: h}) + } + return res +} + +// Handlers returns all the handlers for a given queue, keyed by the routing key +func (h *QueueHandlers) Handlers(queueName string) *Handlers { + if h == nil { + return &Handlers{} + } + + if handlers, ok := (*h)[queueName]; ok { + return handlers + } + return &Handlers{} +} + +// 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) (any, error) + +func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler { + return func(ctx context.Context, event unmarshalEvent) (any, error) { + consumableEvent := ConsumableEvent[T]{ + Metadata: event.Metadata, + DeliveryInfo: event.DeliveryInfo, + } + err := json.Unmarshal(event.Payload, &consumableEvent.Payload) + if err != nil { + return nil, err + } + return handler(ctx, consumableEvent) + } +} + +// overlaps checks if two AMQP binding patterns overlap +func overlaps(p1, p2 string) bool { + if p1 == p2 { + return true + } else if match(p1, p2) { + return true + } else if match(p2, p1) { + return true + } + return false +} + +// match returns true if the AMQP binding pattern is matching the routing key +func match(pattern string, routingKey string) bool { + b, err := regexp.MatchString(fixRegex(pattern), routingKey) + if err != nil { + return false + } + return b +} + +// fixRegex converts the AMQP binding key syntax to regular expression +// For example: +// user.* => user\.[^.]* +// user.# => user\..* +func fixRegex(s string) string { + replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) + return fmt.Sprintf("^%s$", replace) +} diff --git a/headers.go b/headers.go index 8f4d674..02d395f 100644 --- a/headers.go +++ b/headers.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/headers_test.go b/headers_test.go index 6cf4309..2ff7808 100644 --- a/headers_test.go +++ b/headers_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2023 sparetimecoders +// Copyright (c) 2024 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/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/message_logger_test.go b/message_logger_test.go index aaf5d85..e69de29 100644 --- a/message_logger_test.go +++ b/message_logger_test.go @@ -1,46 +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 ( - "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)) -} diff --git a/mocks_test.go b/mocks_test.go index fa0b348..fd18ff3 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/naming.go b/naming.go index ff64bb0..3560859 100644 --- a/naming.go +++ b/naming.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/naming_test.go b/naming_test.go index 0d2a2a8..ce397d4 100644 --- a/naming_test.go +++ b/naming_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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/publish.go b/publish.go index fef1c2c..b8a005a 100644 --- a/publish.go +++ b/publish.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -45,13 +45,7 @@ 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 { +func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) error { table := amqp.Table{} for _, v := range p.defaultHeaders { table[v.Key] = v.Value diff --git a/publishableEvent.go b/publishableEvent.go new file mode 100644 index 0000000..9bf7016 --- /dev/null +++ b/publishableEvent.go @@ -0,0 +1,83 @@ +// MIT License +// +// Copyright (c) 2024 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 ( + "time" + + "github.com/google/uuid" +) + +// PublishableEvent represents an event that can be published. +// The Payload field holds the event's payload data, which can be of +// any type that can be marshal to json. +type PublishableEvent struct { + Metadata + Payload any +} + +type eventOptions struct { + eventID string + correlationID string +} + +// WithEventID specifies the eventID to be published +// if it is not used a random uuid will be generated. +func WithEventID(eventID string) func(*eventOptions) { + return func(opt *eventOptions) { + opt.eventID = eventID + } +} + +// WithCorrelationID specifies the correlationID to be published +// if it is not used a random uuid will be generated. +func WithCorrelationID(correlationID string) func(*eventOptions) { + return func(opt *eventOptions) { + opt.correlationID = correlationID + } +} + +// NewPublishableEvent creates an instance of a PublishableEvent. +// In case the ID and correlation ID are not supplied via options random uuid will be generated. +func NewPublishableEvent(payload any, opts ...func(*eventOptions)) PublishableEvent { + evtOpts := eventOptions{} + for _, opt := range opts { + opt(&evtOpts) + } + + if evtOpts.correlationID == "" { + evtOpts.correlationID = uuid.NewString() + } + if evtOpts.eventID == "" { + evtOpts.eventID = uuid.NewString() + } + + return PublishableEvent{ + Metadata: Metadata{ + ID: evtOpts.eventID, + CorrelationID: evtOpts.correlationID, + Timestamp: time.Now(), + }, + Payload: payload, + } +} diff --git a/logger.go b/tracing.go similarity index 59% rename from logger.go rename to tracing.go index 0e3487d..e8a5f3b 100644 --- a/logger.go +++ b/tracing.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2019 sparetimecoders +// Copyright (c) 2024 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 @@ -22,8 +22,35 @@ package goamqp -// errorLogf function called for error logs -type errorLog func(s string) +import ( + "context" -// noOpLogger log function that does nothing -var noOpLogger = func(s string) {} + 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) amqp.Table { + carrier := propagation.MapCarrier{} + otel.GetTextMapPropagator().Inject(ctx, carrier) + + header := amqp.Table{} + for k, v := range carrier { + header[k] = v + } + return header +} + +// 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.TODO(), carrier) +} From 88e6e76dd3956bd1016dd40ec6886100c1468863 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 17:09:42 +0100 Subject: [PATCH 02/50] chore: wip --- .gitignore | 1 + connection.go | 43 ++++---- connection_options.go | 44 +++++---- connection_options_test.go | 115 ++++++---------------- connection_test.go | 104 ++++++++++--------- example_test.go | 8 +- examples/event-stream/example_test.go | 1 + examples/request-response/example_test.go | 6 +- handler.go | 5 +- metrics.go | 22 +++++ mocks_test.go | 18 ---- notifications.go | 13 +++ 12 files changed, 180 insertions(+), 200 deletions(-) create mode 100644 metrics.go create mode 100644 notifications.go 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/connection.go b/connection.go index 6401662..6ea09c0 100644 --- a/connection.go +++ b/connection.go @@ -44,10 +44,11 @@ type Connection struct { amqpUri amqp.URI connection amqpConnection // TODO One channel per queue/consumer - channel AmqpChannel - queueHandlers *QueueHandlers - typeToKey map[reflect.Type]string - keyToType map[string]reflect.Type + channel AmqpChannel + queueHandlers *QueueHandlers + typeToKey map[reflect.Type]string + keyToType map[string]reflect.Type + notificationCh chan<- Notification } // ServiceResponsePublisher represents the function that is called to publish a response @@ -371,39 +372,39 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue Queue) { for delivery := range deliveries { - //startTime := time.Now() + startTime := time.Now() deliveryInfo := getDeliveryInfo(queue.Name, delivery) - //EventReceived(c.queueName, deliveryInfo.RoutingKey) + EventReceived(queue.Name, deliveryInfo.RoutingKey) // Establish which handler is invoked handler, ok := queue.Handlers.Get(deliveryInfo.RoutingKey) if !ok { - // TODO Handle missing handler? _ = delivery.Reject(false) - - //if c.options.defaultHandler == nil { - // _ = delivery.Nack(false, false) - // EventWithoutHandler(c.queueName, deliveryInfo.RoutingKey) - // continue - //} - //handler = c.options.defaultHandler + EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) + continue } uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} tracingCtx := extractToContext(delivery.Headers) // TODO Handle response if _, err := handler(tracingCtx, uevt); err != nil { - // elapsed := time.Since(startTime).Milliseconds() - // notifyEventHandlerFailed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed, err) - _ = delivery.Nack(false, false) - // EventNack(c.queueName, deliveryInfo.RoutingKey, elapsed) + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) + if errors.Is(err, ErrParseJSON) { + EventNotParsable(queue.Name, deliveryInfo.RoutingKey) + _ = delivery.Nack(false, false) + } else { + _ = delivery.Nack(false, true) + } + EventNack(queue.Name, deliveryInfo.RoutingKey, elapsed) continue } - //elapsed := time.Since(startTime).Milliseconds() - //notifyEventHandlerSucceed(c.options.notificationCh, deliveryInfo.RoutingKey, elapsed) + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) _ = delivery.Ack(false) - //EventAck(c.queueName, deliveryInfo.RoutingKey, elapsed) + EventAck(queue.Name, deliveryInfo.RoutingKey, elapsed) + } } diff --git a/connection_options.go b/connection_options.go index 0cf9a90..f66760e 100644 --- a/connection_options.go +++ b/connection_options.go @@ -66,22 +66,6 @@ func WithHandler[T any](routingKey string, handler EventHandler[T]) Setup { } } -// 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 - } -} - // 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 @@ -98,6 +82,32 @@ func WithPrefetchLimit(limit int) Setup { } } +// WithNotificationChannel specifies a go channel to receive messages +// such as connection established, reconnecting, event published, consumed, etc. +func WithNotificationChannel(notificationCh chan<- Notification) Setup { + return func(conn *Connection) error { + conn.notificationCh = notificationCh + return nil + } +} + +// TODO REMOVE and use WithNotificationChannel instead? +// 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 + } +} + // 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. @@ -193,7 +203,7 @@ func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { // 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], eventType any) Setup { +func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T]) Setup { return func(c *Connection) error { config := &QueueBindingConfig{ routingKey: routingKey, diff --git a/connection_options_test.go b/connection_options_test.go index 969eaf4..0abcfb5 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -35,19 +35,6 @@ import ( "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() @@ -134,58 +121,12 @@ func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { 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.Publish(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) { + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (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]) @@ -203,9 +144,9 @@ func Test_EventStreamConsumer(t *testing.T) { 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) { + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{}, AddQueueNameSuffix("suffix"))) + }, 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]) @@ -223,18 +164,18 @@ func Test_EventStreamConsumerWithOptFunc(t *testing.T) { 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) { + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{}, AddQueueNameSuffix(""))) + }, 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) { + err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{})) + })) require.NoError(t, err) require.Equal(t, 2, len(channel.ExchangeDeclarations)) @@ -256,9 +197,9 @@ func Test_ServiceRequestConsumer_ExchangeDeclareError(t *testing.T) { declareError := errors.New("failed") channel.ExchangeDeclarationError = &declareError conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(i any, headers Headers) (any, error) { + err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{})) + })) require.ErrorContains(t, err, "failed, failed to create exchange svc.headers.exchange.response: failed") } @@ -266,9 +207,9 @@ func Test_ServiceRequestConsumer_ExchangeDeclareError(t *testing.T) { 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) { + err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{})) + })) require.NoError(t, err) require.Equal(t, 1, len(channel.ExchangeDeclarations)) @@ -289,9 +230,9 @@ func Test_ServiceResponseConsumer_ExchangeDeclareError(t *testing.T) { 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) { + err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, nil - }, TestMessage{})) + })) require.ErrorContains(t, err, " failed, actual error message") } @@ -299,9 +240,9 @@ func Test_ServiceResponseConsumer_ExchangeDeclareError(t *testing.T) { func Test_RequestResponseHandler(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := RequestResponseHandler("key", func(msg any, headers Headers) (response any, err error) { + err := RequestResponseHandler("key", func(ctx context.Context, msg ConsumableEvent[Message]) (response any, err error) { return nil, nil - }, Message{})(conn) + })(conn) require.NoError(t, err) require.Equal(t, 2, len(channel.ExchangeDeclarations)) @@ -316,9 +257,8 @@ func Test_RequestResponseHandler(t *testing.T) { 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()) + handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").Get("key") + require.Equal(t, "github.com/sparetimecoders/goamqp.responseWrapper.func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) } func Test_ServicePublisher_Ok(t *testing.T) { @@ -412,9 +352,9 @@ 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) { + err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, errors.New("failed") - }, Message{})(conn) + })(conn) require.NoError(t, err) require.Equal(t, 1, len(channel.BindingDeclarations)) @@ -427,19 +367,20 @@ func Test_TransientEventStreamConsumer_Ok(t *testing.T) { 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) + handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").Get("key") + require.True(t, ok) + require.NotNil(t, handler) } 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{})) + require.NoError(t, conn.queueHandlers.Add("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", "root.key", nil)) uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("root.#", func(i any, headers Headers) (any, error) { + err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { return nil, errors.New("failed") - }, Message{})(conn) + })(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") } @@ -463,9 +404,9 @@ func testTransientEventStreamConsumerFailure(t *testing.T, channel *MockAmqpChan conn := mockConnection(channel) uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(i any, headers Headers) (any, error) { + err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { return nil, errors.New("failed") - }, Message{})(conn) + })(conn) require.EqualError(t, err, expectedError) } diff --git a/connection_test.go b/connection_test.go index 79d7a16..5bfecb5 100644 --- a/connection_test.go +++ b/connection_test.go @@ -97,12 +97,12 @@ func Test_Start_SetupFails(t *testing.T) { serviceName: "test", connection: mockAmqpConnection, channel: mockChannel, - queueHandlers: &handlers2.QueueHandlers[messageHandlerInvoker]{}, + queueHandlers: &QueueHandlers{}, } err := conn.Start(context.Background(), - EventStreamConsumer("test", func(i any, headers Headers) (any, error) { + EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) (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") } @@ -291,8 +291,7 @@ func Test_Publish(t *testing.T) { headers := amqp.Table{} headers["key"] = "value" c := Connection{ - channel: channel, - messageLogger: noOpMessageLogger(), + channel: channel, } err := c.publishMessage(context.Background(), Message{true}, "key", "exchange", headers) require.NoError(t, err) @@ -327,8 +326,7 @@ func Test_Publish_Marshal_Error(t *testing.T) { headers := amqp.Table{} headers["key"] = "value" c := Connection{ - channel: channel, - messageLogger: noOpMessageLogger(), + channel: channel, } err := c.publishMessage(context.Background(), math.Inf(1), "key", "exchange", headers) require.EqualError(t, err, "json: unsupported value: +Inf") @@ -390,9 +388,11 @@ func TestResponseWrapper(t *testing.T) { if tt.headers != nil { headers = *tt.headers } - resp, err := responseWrapper(func(i any, headers Headers) (any, error) { + resp, err := responseWrapper(func(ctx context.Context, msg 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) @@ -411,18 +411,15 @@ func Test_DivertToMessageHandler(t *testing.T) { } 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)) + handlers := QueueHandlers{} + handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { + if msg.Payload.Ok { + return nil, nil + } + return nil, errors.New("failed") + }) + require.NoError(t, handlers.Add("q", "key1", handler)) + require.NoError(t, handlers.Add("q", "key2", handler)) queueDeliveries := make(chan amqp.Delivery, 6) @@ -433,12 +430,10 @@ func Test_DivertToMessageHandler(t *testing.T) { close(queueDeliveries) c := Connection{ - started: true, - channel: &channel, - messageLogger: noOpMessageLogger(), - errorLog: noOpLogger, + started: true, + channel: &channel, } - c.divertToMessageHandlers(queueDeliveries, handlers.Queues()[0].Handlers) + c.divertToMessageHandlers(queueDeliveries, handlers.Queues()[0]) require.Equal(t, 1, len(acker.Rejects)) require.Equal(t, 1, len(acker.Nacks)) @@ -455,7 +450,6 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { cfg := &QueueBindingConfig{ routingKey: "routingkey", handler: nil, - eventType: nil, queueName: "queue", exchangeName: "exchange", kind: kindDirect, @@ -483,8 +477,8 @@ 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 Test_HandleMessage_Nack_IfParseFails(t *testing.T) { + require.Equal(t, Nack{tag: 0x0, requeue: false}, <-testHandleMessage("", true).Nacks) } func testHandleMessage(json string, handle bool) MockAcknowledger { @@ -493,17 +487,26 @@ func testHandleMessage(json string, handle bool) MockAcknowledger { delivery := amqp.Delivery{ Body: []byte(json), Acknowledger: &acker, + RoutingKey: "key", } - c := &Connection{ - messageLogger: noOpMessageLogger(), - errorLog: noOpLogger, + c := &Connection{} + deliveries := make(chan amqp.Delivery) + queue := Queue{ + Name: "", + Handlers: &Handlers{ + "key": newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { + if handle { + return nil, nil + } + return nil, errors.New("failed") + }), + }, } - c.handleMessage(delivery, func(i any, headers Headers) (any, error) { - if handle { - return nil, nil - } - return nil, errors.New("failed") - }, reflect.TypeOf(Message{})) + go func() { + deliveries <- delivery + close(deliveries) + }() + c.divertToMessageHandlers(deliveries, queue) return acker } @@ -529,7 +532,7 @@ func Test_HandleMessage_RecoverableError(t *testing.T) { 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(), TestMessage{Msg: "test"}, Header{"service", "header"}) require.EqualError(t, err, "reserved key service used, please change to use another one") } @@ -570,7 +573,7 @@ func TestConnection_TypeMappingHandler(t *testing.T) { keyToType map[string]reflect.Type } type args struct { - handler func(t *testing.T) HandlerFunc + handler func(t *testing.T) Handler msg json.RawMessage key string } @@ -587,8 +590,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { args: args{ msg: []byte(`{"a":true}`), key: "unknown", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { + handler: func(t *testing.T) Handler { + return func(ctx context.Context, msg any) (response any, err error) { return nil, nil } }, @@ -606,8 +609,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { args: args{ msg: []byte(`{"a:}`), key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { + handler: func(t *testing.T) Handler { + return func(ctx context.Context, msg any) (response any, err error) { return nil, nil } }, @@ -627,8 +630,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { args: args{ msg: []byte(`{"a":true}`), key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { + handler: func(t *testing.T) Handler { + return func(ctx context.Context, msg any) (response any, err error) { assert.IsType(t, &TestMessage{}, msg) return nil, fmt.Errorf("handler-error") } @@ -649,8 +652,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { args: args{ msg: []byte(`{"a":true}`), key: "known", - handler: func(t *testing.T) HandlerFunc { - return func(msg any, headers Headers) (response any, err error) { + handler: func(t *testing.T) Handler { + return func(ctx context.Context, msg any) (response any, err error) { assert.IsType(t, &TestMessage{}, msg) return "OK", nil } @@ -668,7 +671,10 @@ func TestConnection_TypeMappingHandler(t *testing.T) { } handler := c.TypeMappingHandler(tt.args.handler(t)) - res, err := handler(&tt.args.msg, headers(make(amqp.Table), tt.args.key)) + res, err := handler(context.TODO(), ConsumableEvent[json.RawMessage]{ + Payload: tt.args.msg, + DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, + }) if !tt.wantErr(t, err) { return } diff --git a/example_test.go b/example_test.go index 175d243..01a6a74 100644 --- a/example_test.go +++ b/example_test.go @@ -40,11 +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, IncomingMessage{"OK"}) checkError(err) time.Sleep(time.Second) err = connection.Close() @@ -58,8 +58,8 @@ func checkError(err error) { } } -func process(m any, headers Headers) (any, error) { - fmt.Printf("Called process with %v\n", m.(*IncomingMessage).Data) +func process(ctx context.Context, m ConsumableEvent[IncomingMessage]) (any, error) { + fmt.Printf("Called process with %v\n", m.Payload.Data) return nil, nil } diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 5e1d101..856ba58 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -84,6 +84,7 @@ func (s *StatService) handleOrderUpdated(ctx context.Context, msg ConsumableEven fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) return nil, nil } + func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) (response any, err error) { // Just to make sure the Output is correct in the example... fmt.Printf("Created order, %s\n", msg.Payload.Id) diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index db66a9d..597fb23 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -48,7 +48,7 @@ func Example_request_response() { err = clientConnection.Start(ctx, WithTypeMapping(routingKey, Request{}), ServicePublisher("service", publisher), - ServiceResponseConsumer("service", routingKey, handleResponse, Response{}), + ServiceResponseConsumer("service", routingKey, handleResponse), ) checkError(err) @@ -60,8 +60,8 @@ func Example_request_response() { _ = clientConnection.Close() // Output: - //Called process with test, returning response {test} - //Got response, test + // Called process with test, returning response {test} + // Got response, test } func checkError(err error) { diff --git a/handler.go b/handler.go index 30b0399..30ee16d 100644 --- a/handler.go +++ b/handler.go @@ -25,6 +25,7 @@ package goamqp import ( "context" "encoding/json" + "errors" "fmt" "regexp" "strings" @@ -125,12 +126,14 @@ func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler { } err := json.Unmarshal(event.Payload, &consumableEvent.Payload) if err != nil { - return nil, err + return nil, fmt.Errorf("%v: %w", err, ErrParseJSON) } return handler(ctx, consumableEvent) } } +var ErrParseJSON = errors.New("failed to parse") + // overlaps checks if two AMQP binding patterns overlap func overlaps(p1, p2 string) bool { if p1 == p2 { diff --git a/metrics.go b/metrics.go new file mode 100644 index 0000000..a2baf8d --- /dev/null +++ b/metrics.go @@ -0,0 +1,22 @@ +package goamqp + +func EventReceived(queue string, routingKey string) { +} + +func EventWithoutHandler(queue string, routingKey string) { +} + +func EventNotParsable(queue string, routingKey string) { +} + +func EventNack(queue string, routingKey string, milliseconds int64) { +} + +func EventAck(queue string, routingKey string, milliseconds int64) { +} + +func EventPublishSucceed(exchange string, routingKey string) { +} + +func EventPublishFailed(exchange string, routingKey string) { +} diff --git a/mocks_test.go b/mocks_test.go index fd18ff3..75ed762 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -25,7 +25,6 @@ package goamqp import ( "context" "errors" - "reflect" amqp "github.com/rabbitmq/amqp091-go" ) @@ -255,7 +254,6 @@ func mockConnection(channel *MockAmqpChannel) *Connection { c := newConnection("svc", amqp.URI{}) c.channel = channel c.connection = &MockAmqpConnection{} - c.messageLogger = noOpMessageLogger() return c } @@ -267,19 +265,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/notifications.go b/notifications.go new file mode 100644 index 0000000..0c6919a --- /dev/null +++ b/notifications.go @@ -0,0 +1,13 @@ +package goamqp + +type Notification struct { + Message string + // Type NotificationType + // Source NotificationSource +} + +func notifyEventHandlerSucceed(ch chan<- Notification, routingKey string, took int64) { +} + +func notifyEventHandlerFailed(ch chan<- Notification, routingKey string, took int64, err error) { +} From 74c748540a25772c1f2debd90107583e9eddaecd Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 19:04:02 +0100 Subject: [PATCH 03/50] chore: wip --- _integration/amqp_admin.go | 77 +++++++-- _integration/integration_test.go | 193 +++++++++++----------- connection.go | 53 ++---- connection_options.go | 33 +++- connection_options_test.go | 44 ++--- connection_test.go | 44 ++--- example_test.go | 4 +- examples/event-stream/example_test.go | 19 ++- examples/request-response/example_test.go | 4 +- handler.go | 27 +-- 10 files changed, 279 insertions(+), 219 deletions(-) diff --git a/_integration/amqp_admin.go b/_integration/amqp_admin.go index e32c4bd..b7a8e8d 100644 --- a/_integration/amqp_admin.go +++ b/_integration/amqp_admin.go @@ -26,34 +26,45 @@ import ( "encoding/json" "fmt" "io" + "log" "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 ParseAmqpURL(amqpURL string) *amqpAdmin { + 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 } + log.Panicf("invalid Amqp URL: %s", amqpURL) + return nil } func (a *amqpAdmin) CreateVHost() error { - _, err := a.request(http.MethodPut, fmt.Sprintf("/vhosts/%s", a.vhost), nil) + _, 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) + _, err := a.request(http.MethodDelete, fmt.Sprintf("/vhosts/%s", a.VHost), nil) return err } @@ -69,7 +80,7 @@ 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 } @@ -99,7 +110,7 @@ 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 } @@ -109,7 +120,7 @@ func (a *amqpAdmin) GetQueues() ([]Queue, error) { } 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 } @@ -132,7 +143,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 +210,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 index 6f7afae..f26b946 100644 --- a/_integration/integration_test.go +++ b/_integration/integration_test.go @@ -25,11 +25,11 @@ package _integration import ( "context" "fmt" + "os" "regexp" "testing" "time" - "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -37,13 +37,14 @@ import ( ) var ( - amqpUser = "user" - amqpPasswod = "password" - amqpHost = "localhost" - amqpPort = 5672 - amqpAdminPort = 15672 - amqpURL = fmt.Sprintf("amqp://%s:%s@%s:%d", amqpUser, amqpPasswod, amqpHost, amqpPort) + // 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/test" ) type IntegrationTestSuite struct { @@ -52,7 +53,11 @@ type IntegrationTestSuite struct { } func (suite *IntegrationTestSuite) SetupTest() { - suite.admin = AmqpAdmin(amqpHost, amqpAdminPort, amqpUser, amqpPasswod, uuid.New().String()) + if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { + amqpURL = urlFromEnv + } + suite.admin = ParseAmqpURL(amqpURL) + require.NotNil(suite.T(), suite.admin) err := suite.admin.CreateVHost() require.NoError(suite.T(), err) } @@ -68,9 +73,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,14 +87,14 @@ 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() @@ -103,14 +108,14 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { 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 +125,39 @@ 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) + WithTypeMapping(routingKey, Incoming{}), + 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(), &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 @@ -187,7 +193,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { 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 +201,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, @@ -212,7 +218,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { 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 +226,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, @@ -235,36 +241,37 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { clientQuery := "test" publish := NewPublisher() server := createConnection(suite, serverServiceName, - EventStreamPublisher(publish)) + EventStreamPublisher(publish), + WithTypeMapping(routingKey, Incoming{})) 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(), &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 @@ -291,7 +298,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { 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 +306,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, @@ -319,7 +326,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { 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 +334,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, @@ -351,29 +358,29 @@ func (suite *IntegrationTestSuite) Test_EventStream() { 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 { + 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 { + 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(), &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), &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 +391,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 @@ -404,12 +411,12 @@ func (suite *IntegrationTestSuite) Test_EventStream() { 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, @@ -425,12 +432,12 @@ func (suite *IntegrationTestSuite) Test_EventStream() { 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, @@ -471,37 +478,37 @@ 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(), &Test{Test: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &Incoming{Query: clientQuery}) + err = publish.Publish(context.Background(), &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.PublishWithContext(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), &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,7 +519,7 @@ 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 @@ -530,12 +537,12 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { 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 +550,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 +558,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,7 +578,7 @@ 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) diff --git a/connection.go b/connection.go index 6ea09c0..a969d5e 100644 --- a/connection.go +++ b/connection.go @@ -162,25 +162,6 @@ func (c *Connection) Close() error { return c.connection.Close() } -func (c *Connection) TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { - return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) (any, error) { - routingKey := event.DeliveryInfo.RoutingKey - typ, exists := c.keyToType[routingKey] - if !exists { - return nil, nil - } - message := reflect.New(typ).Interface() - if err := json.Unmarshal(event.Payload, &message); err != nil { - return nil, err - } - if resp, err := handler(ctx, message); 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 @@ -229,25 +210,22 @@ func version() string { return "_unknown_" } -func responseWrapper[T, R any](handler EventHandler[T], routingKey string, publisher ServiceResponsePublisher[R]) EventHandler[T] { - return func(ctx context.Context, event ConsumableEvent[T]) (response any, err error) { +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 nil, errors.Wrap(err, "failed to process message") + return errors.Wrap(err, "failed to process message") } - if resp != nil { - service, err := sendingService(event.DeliveryInfo) - if err != nil { - return nil, errors.Wrap(err, "failed to extract service name") - } - // TODO Handle response with type R - err = publisher(ctx, service, routingKey, resp.(R)) - if err != nil { - return nil, errors.Wrapf(err, "failed to publish response") - } - return resp, nil + service, err := sendingService(event.DeliveryInfo) + if err != nil { + return errors.Wrap(err, "failed to extract service name") } - return nil, nil + // TODO Handle response with type R + err = publisher(ctx, service, routingKey, resp) + if err != nil { + return errors.Wrapf(err, "failed to publish response") + } + return nil } } @@ -370,14 +348,14 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { return deliveryInfo } -func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue Queue) { +func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue QueueWithHandlers) { for delivery := range deliveries { startTime := time.Now() deliveryInfo := getDeliveryInfo(queue.Name, delivery) EventReceived(queue.Name, deliveryInfo.RoutingKey) // Establish which handler is invoked - handler, ok := queue.Handlers.Get(deliveryInfo.RoutingKey) + handler, ok := queue.Handlers.get(deliveryInfo.RoutingKey) if !ok { _ = delivery.Reject(false) EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) @@ -387,7 +365,7 @@ func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, qu uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} tracingCtx := extractToContext(delivery.Headers) // TODO Handle response - if _, err := handler(tracingCtx, uevt); err != nil { + if err := handler(tracingCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) if errors.Is(err, ErrParseJSON) { @@ -454,6 +432,7 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { func (c *Connection) setup() error { for _, queue := range c.queueHandlers.Queues() { + // TODO one channel per queue` consumer, err := consume(c.channel, queue.Name) if err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", queue.Name, err) diff --git a/connection_options.go b/connection_options.go index f66760e..6fee5b8 100644 --- a/connection_options.go +++ b/connection_options.go @@ -23,6 +23,7 @@ package goamqp import ( + "context" "fmt" "reflect" @@ -50,6 +51,32 @@ func WithTypeMapping(routingKey string, msgType any) Setup { } } +//func WithTypeMappingHandler(handler Handler) Setup { +// return func(c *Connection) error { +// return nil +// } +//} +/* + return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { + routingKey := event.DeliveryInfo.RoutingKey + typ, exists := c.keyToType[routingKey] + if !exists { + return nil + } + message := reflect.New(typ).Interface() + if err := json.Unmarshal(event.Payload, &message); err != nil { + return err + } + if err := handler(ctx, message); err == nil { + return nil + } else { + return err + } + } +} + +*/ + func WithHandler[T any](routingKey string, handler EventHandler[T]) Setup { exchangeName := topicExchangeName(defaultEventExchangeName) return func(c *Connection) error { @@ -258,9 +285,11 @@ func ServicePublisher(targetService string, publisher *Publisher) Setup { // RequestResponseHandler is a convenience func to set up ServiceRequestConsumer and combines it with // PublishServiceResponse -func RequestResponseHandler[T any](routingKey string, handler EventHandler[T]) Setup { +func RequestResponseHandler[T any, R any](routingKey string, handler RequestResponseEventHandler[T, R]) Setup { return func(c *Connection) error { - responseHandlerWrapper := responseWrapper(handler, routingKey, c.PublishServiceResponse) + 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) } } diff --git a/connection_options_test.go b/connection_options_test.go index 0abcfb5..8038178 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -124,8 +124,8 @@ func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { func Test_EventStreamConsumer(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil })) require.NoError(t, err) require.Equal(t, 1, len(channel.ExchangeDeclarations)) @@ -144,8 +144,8 @@ func Test_EventStreamConsumer(t *testing.T) { func Test_EventStreamConsumerWithOptFunc(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil }, AddQueueNameSuffix("suffix"))) require.NoError(t, err) require.Equal(t, 1, len(channel.ExchangeDeclarations)) @@ -164,8 +164,8 @@ func Test_EventStreamConsumerWithOptFunc(t *testing.T) { func Test_EventStreamConsumerWithFailingOptFunc(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil }, AddQueueNameSuffix(""))) require.ErrorContains(t, err, "failed, empty queue suffix not allowed") } @@ -173,8 +173,8 @@ func Test_EventStreamConsumerWithFailingOptFunc(t *testing.T) { func Test_ServiceRequestConsumer_Ok(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil })) require.NoError(t, err) @@ -197,8 +197,8 @@ func Test_ServiceRequestConsumer_ExchangeDeclareError(t *testing.T) { declareError := errors.New("failed") channel.ExchangeDeclarationError = &declareError conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), ServiceRequestConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil })) require.ErrorContains(t, err, "failed, failed to create exchange svc.headers.exchange.response: failed") @@ -207,8 +207,8 @@ func Test_ServiceRequestConsumer_ExchangeDeclareError(t *testing.T) { func Test_ServiceResponseConsumer_Ok(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil })) require.NoError(t, err) @@ -230,8 +230,8 @@ func Test_ServiceResponseConsumer_ExchangeDeclareError(t *testing.T) { declareError := errors.New("actual error message") channel.ExchangeDeclarationError = &declareError conn := mockConnection(channel) - err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, nil + err := conn.Start(context.Background(), ServiceResponseConsumer("targetService", "key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil })) require.ErrorContains(t, err, " failed, actual error message") @@ -257,7 +257,7 @@ func Test_RequestResponseHandler(t *testing.T) { require.Equal(t, 1, len(conn.queueHandlers.Queues())) - handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").Get("key") + handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").get("key") require.Equal(t, "github.com/sparetimecoders/goamqp.responseWrapper.func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) } @@ -352,8 +352,8 @@ func Test_TransientEventStreamConsumer_Ok(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, errors.New("failed") + err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") })(conn) require.NoError(t, err) @@ -367,7 +367,7 @@ func Test_TransientEventStreamConsumer_Ok(t *testing.T) { 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())) - handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").Get("key") + handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").get("key") require.True(t, ok) require.NotNil(t, handler) } @@ -378,8 +378,8 @@ func Test_TransientEventStreamConsumer_HandlerForRoutingKeyAlreadyExists(t *test require.NoError(t, conn.queueHandlers.Add("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", "root.key", nil)) uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) (any, error) { - return nil, errors.New("failed") + err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") })(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") @@ -404,8 +404,8 @@ func testTransientEventStreamConsumerFailure(t *testing.T, channel *MockAmqpChan conn := mockConnection(channel) uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { - return nil, errors.New("failed") + err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[Message]) error { + return errors.New("failed") })(conn) require.EqualError(t, err, expectedError) diff --git a/connection_test.go b/connection_test.go index 5bfecb5..4c05bf0 100644 --- a/connection_test.go +++ b/connection_test.go @@ -32,7 +32,6 @@ import ( "testing" amqp "github.com/rabbitmq/amqp091-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -100,8 +99,8 @@ func Test_Start_SetupFails(t *testing.T) { queueHandlers: &QueueHandlers{}, } err := conn.Start(context.Background(), - EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { - return nil, errors.New("failed") + 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") @@ -379,7 +378,7 @@ func TestResponseWrapper(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - p := &mockPublisher{ + p := &mockPublisher[any]{ err: tt.publisherErr, published: nil, } @@ -388,14 +387,14 @@ func TestResponseWrapper(t *testing.T) { if tt.headers != nil { headers = *tt.headers } - resp, err := responseWrapper(func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { + err := responseWrapper(func(ctx context.Context, event ConsumableEvent[Message]) (any, error) { return tt.handlerResp, tt.handlerErr }, "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()) } @@ -412,11 +411,11 @@ func Test_DivertToMessageHandler(t *testing.T) { channel := MockAmqpChannel{Published: make(chan Publish, 1)} handlers := QueueHandlers{} - handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { + handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { if msg.Payload.Ok { - return nil, nil + return nil } - return nil, errors.New("failed") + return errors.New("failed") }) require.NoError(t, handlers.Add("q", "key1", handler)) require.NoError(t, handlers.Add("q", "key2", handler)) @@ -491,14 +490,14 @@ func testHandleMessage(json string, handle bool) MockAcknowledger { } c := &Connection{} deliveries := make(chan amqp.Delivery) - queue := Queue{ + queue := QueueWithHandlers{ Name: "", Handlers: &Handlers{ - "key": newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) (any, error) { + "key": newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { if handle { - return nil, nil + return nil } - return nil, errors.New("failed") + return errors.New("failed") }), }, } @@ -550,12 +549,12 @@ 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 } @@ -563,11 +562,11 @@ 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) { +/*func TestConnection_TypeMappingHandler(t *testing.T) { type fields struct { typeToKey map[reflect.Type]string keyToType map[string]reflect.Type @@ -591,8 +590,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { msg: []byte(`{"a":true}`), key: "unknown", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) (response any, err error) { - return nil, nil + return func(ctx context.Context, msg any) error { + return nil } }, }, @@ -610,8 +609,8 @@ func TestConnection_TypeMappingHandler(t *testing.T) { msg: []byte(`{"a:}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) (response any, err error) { - return nil, nil + return func(ctx context.Context, msg any) error { + return nil } }, }, @@ -682,3 +681,4 @@ func TestConnection_TypeMappingHandler(t *testing.T) { }) } } +*/ diff --git a/example_test.go b/example_test.go index 01a6a74..942ed8b 100644 --- a/example_test.go +++ b/example_test.go @@ -58,9 +58,9 @@ func checkError(err error) { } } -func process(ctx context.Context, m ConsumableEvent[IncomingMessage]) (any, error) { +func process(ctx context.Context, m ConsumableEvent[IncomingMessage]) error { fmt.Printf("Called process with %v\n", m.Payload.Data) - return nil, nil + return nil } type IncomingMessage struct { diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 856ba58..c3c3334 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -30,7 +30,7 @@ import ( ) // var amqpURL = "amqp://user:password@localhost:5672/test" -var amqpURL = "amqp://user:password@goodfeed-control-plane.orb.local:5672/test" +var amqpURL = "amqp://user:password@localhostl:5672/test" func Test_A(t *testing.T) { ctx := context.Background() @@ -80,15 +80,15 @@ func (s *StatService) Start(ctx context.Context) error { ) } -func (s *StatService) handleOrderUpdated(ctx context.Context, msg ConsumableEvent[OrderUpdated]) (response any, err error) { +func (s *StatService) handleOrderUpdated(ctx context.Context, msg ConsumableEvent[OrderUpdated]) error { fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) - return nil, nil + return nil } -func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) (response any, err error) { +func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) error { // Just to make sure the Output is correct in the example... fmt.Printf("Created order, %s\n", msg.Payload.Id) - return nil, nil + return nil } // -- ShippingService @@ -106,10 +106,11 @@ func (s *ShippingService) Start(ctx context.Context) error { return s.connection.Start(ctx, WithTypeMapping("Order.Created", OrderCreated{}), WithTypeMapping("Order.Updated", OrderUpdated{}), - WithHandler("#", s.connection.TypeMappingHandler(func(ctx context.Context, event any) (any, error) { - return s.handleOrderEvent(ctx, event) - }), - )) + //WithHandler("#", s.connection.TypeMappingHandler(func(ctx context.Context, event any) (any, error) { + // return s.handleOrderEvent(ctx, event) + //}), + //) + ) } func (s *ShippingService) handleOrderEvent(ctx context.Context, msg any) (response any, err error) { diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index 597fb23..192d963 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -76,9 +76,9 @@ func handleRequest(ctx context.Context, m ConsumableEvent[Request]) (any, error) return response, nil } -func handleResponse(ctx context.Context, m ConsumableEvent[Response]) (any, error) { +func handleResponse(ctx context.Context, m ConsumableEvent[Response]) error { fmt.Printf("Got response, %v\n", m.Payload.Data) - return nil, nil + return nil } type Request struct { diff --git a/handler.go b/handler.go index 30ee16d..b5bf5dd 100644 --- a/handler.go +++ b/handler.go @@ -31,20 +31,23 @@ import ( "strings" ) -type Handler func(ctx context.Context, event any) (any, error) +// type Handler func(ctx context.Context, event any) error // EventHandler is the type definition for a function that is used to handle events of a specific type. // TODO 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 EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) (any, error) +type ( + EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) error + RequestResponseEventHandler[T any, R any] func(ctx context.Context, event ConsumableEvent[T]) (R, error) +) // Handlers holds the handlers for a certain queue type Handlers map[string]wrappedHandler -// Get returns the handler for the given queue and routing key that matches -func (h *Handlers) Get(routingKey string) (wrappedHandler, bool) { +// get returns the handler for the given queue and routing key that matches +func (h *Handlers) get(routingKey string) (wrappedHandler, bool) { for mappedRoutingKey, handler := range *h { if match(mappedRoutingKey, routingKey) { return handler, true @@ -85,19 +88,19 @@ func (h *QueueHandlers) Add(queueName, routingKey string, handler wrappedHandler return nil } -type Queue struct { +type QueueWithHandlers struct { Name string Handlers *Handlers } // Queues returns all queue names for which we have added a handler -func (h *QueueHandlers) Queues() []Queue { +func (h *QueueHandlers) Queues() []QueueWithHandlers { if h == nil { - return []Queue{} + return []QueueWithHandlers{} } - var res []Queue + var res []QueueWithHandlers for q, h := range *h { - res = append(res, Queue{Name: q, Handlers: h}) + res = append(res, QueueWithHandlers{Name: q, Handlers: h}) } return res } @@ -116,17 +119,17 @@ func (h *QueueHandlers) Handlers(queueName string) *Handlers { // 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) (any, error) +type wrappedHandler func(ctx context.Context, event unmarshalEvent) error func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler { - return func(ctx context.Context, event unmarshalEvent) (any, error) { + 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 nil, fmt.Errorf("%v: %w", err, ErrParseJSON) + return fmt.Errorf("%v: %w", err, ErrParseJSON) } return handler(ctx, consumableEvent) } From f075555031a34fe8bde24793ffb3a98d74be70c8 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 19:10:37 +0100 Subject: [PATCH 04/50] chore: wip --- connection.go | 3 +-- connection_options_test.go | 2 +- tracing.go | 7 +++---- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/connection.go b/connection.go index a969d5e..8dc74da 100644 --- a/connection.go +++ b/connection.go @@ -327,9 +327,8 @@ func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, ex Body: jsonBytes, ContentType: contentType, DeliveryMode: 2, - Headers: headers, + Headers: injectToHeaders(ctx, headers), } - return c.channel.PublishWithContext(ctx, exchangeName, routingKey, false, diff --git a/connection_options_test.go b/connection_options_test.go index 8038178..93cce83 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -258,7 +258,7 @@ func Test_RequestResponseHandler(t *testing.T) { require.Equal(t, 1, len(conn.queueHandlers.Queues())) handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").get("key") - require.Equal(t, "github.com/sparetimecoders/goamqp.responseWrapper.func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) } func Test_ServicePublisher_Ok(t *testing.T) { diff --git a/tracing.go b/tracing.go index e8a5f3b..8be3753 100644 --- a/tracing.go +++ b/tracing.go @@ -31,15 +31,14 @@ import ( ) // inject the span context to amqp table -func injectToHeaders(ctx context.Context) amqp.Table { +func injectToHeaders(ctx context.Context, headers amqp.Table) amqp.Table { carrier := propagation.MapCarrier{} otel.GetTextMapPropagator().Inject(ctx, carrier) - header := amqp.Table{} for k, v := range carrier { - header[k] = v + headers[k] = v } - return header + return headers } // extract the amqp table to a span context From d8703e5e670905b637afad922f8cc4bdf9d4611a Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 20:03:32 +0100 Subject: [PATCH 05/50] chore: refactor --- connection.go | 8 +- connection_options.go | 162 +----------------- connection_options_test.go | 227 ++------------------------ connection_test.go | 8 +- consumer.go | 102 ++++++++++++ consumer_test.go | 190 +++++++++++++++++++++ examples/event-stream/example_test.go | 2 +- go.mod | 1 - go.sum | 33 ++-- headers.go | 2 +- publish.go | 53 ++++++ request_responae.go | 14 ++ request_response_test.go | 35 ++++ 13 files changed, 435 insertions(+), 402 deletions(-) create mode 100644 consumer.go create mode 100644 consumer_test.go create mode 100644 request_responae.go create mode 100644 request_response_test.go diff --git a/connection.go b/connection.go index 8dc74da..71c05b0 100644 --- a/connection.go +++ b/connection.go @@ -25,6 +25,7 @@ package goamqp import ( "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -33,7 +34,6 @@ import ( "runtime/debug" "time" - "github.com/pkg/errors" amqp "github.com/rabbitmq/amqp091-go" ) @@ -214,16 +214,16 @@ func responseWrapper[T, R any](handler RequestResponseEventHandler[T, R], routin return func(ctx context.Context, event ConsumableEvent[T]) (err error) { resp, err := handler(ctx, event) if err != nil { - return errors.Wrap(err, "failed to process message") + return fmt.Errorf("failed to process message, %w", err) } service, err := sendingService(event.DeliveryInfo) if err != nil { - return errors.Wrap(err, "failed to extract service name") + return fmt.Errorf("failed to extract service name, %w", err) } // TODO Handle response with type R err = publisher(ctx, service, routingKey, resp) if err != nil { - return errors.Wrapf(err, "failed to publish response") + return fmt.Errorf("failed to publish response, %w", err) } return nil } diff --git a/connection_options.go b/connection_options.go index 6fee5b8..1635d47 100644 --- a/connection_options.go +++ b/connection_options.go @@ -23,11 +23,10 @@ package goamqp import ( - "context" + "errors" "fmt" "reflect" - "github.com/pkg/errors" amqp "github.com/rabbitmq/amqp091-go" ) @@ -135,165 +134,6 @@ func CloseListener(e chan error) Setup { } } -// 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]) Setup { - return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) -} - -// 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 ...QueueBindingConfigSetup) Setup { - return StreamConsumer(defaultEventExchangeName, routingKey, handler, 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[T any](exchange, routingKey string, handler EventHandler[T]) Setup { - exchangeName := topicExchangeName(exchange) - return func(c *Connection) error { - queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) - if err := c.addHandler(queueName, routingKey, newWrappedHandler(handler)); 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[T any](exchange, routingKey string, handler EventHandler[T], opts ...QueueBindingConfigSetup) Setup { - exchangeName := topicExchangeName(exchange) - return func(c *Connection) error { - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - 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[T any](targetService, routingKey string, handler EventHandler[T]) Setup { - return func(c *Connection) error { - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - 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[T any](routingKey string, handler EventHandler[T]) Setup { - return func(c *Connection) error { - 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: newWrappedHandler(handler), - 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[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) - } -} - // PublishNotify see amqp.Channel.Confirm func PublishNotify(confirm chan amqp.Confirmation) Setup { return func(c *Connection) error { diff --git a/connection_options_test.go b/connection_options_test.go index 93cce83..c346c98 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -26,10 +26,8 @@ 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" @@ -49,17 +47,16 @@ func Test_CloseListener(t *testing.T) { require.EqualError(t, err, "Exception (123) Reason: \"Close reason\"") } -func Test_EventStreamPublisher_Ok(t *testing.T) { +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 := EventStreamPublisher(p)(conn) + err := QueuePublisher(p, "destQueue")(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.ExchangeDeclarations)) require.Equal(t, 0, len(channel.QueueDeclarations)) require.Equal(t, 0, len(channel.BindingDeclarations)) @@ -74,21 +71,23 @@ func Test_EventStreamPublisher_Ok(t *testing.T) { require.NoError(t, err) published = <-channel.Published - require.Equal(t, 2, len(published.msg.Headers)) + 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_QueuePublisher_Ok(t *testing.T) { +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 := QueuePublisher(p, "destQueue")(conn) + err := EventStreamPublisher(p)(conn) require.NoError(t, err) - require.Equal(t, 0, len(channel.ExchangeDeclarations)) + 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)) @@ -103,10 +102,9 @@ func Test_QueuePublisher_Ok(t *testing.T) { require.NoError(t, err) published = <-channel.Published - require.Equal(t, 3, len(published.msg.Headers)) + 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"]) - require.Equal(t, "destQueue", published.msg.Headers["CC"].([]any)[0]) } func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { @@ -118,147 +116,7 @@ func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { 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_EventStreamConsumer(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - require.ErrorContains(t, err, " failed, actual error message") -} - -func Test_RequestResponseHandler(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := RequestResponseHandler("key", func(ctx context.Context, msg ConsumableEvent[Message]) (response any, err error) { - return nil, 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: 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())) - - handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").get("key") - require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + require.EqualError(t, err, "failed to declare exchange events.topic.exchange, failed to create exchange") } func Test_ServicePublisher_Ok(t *testing.T) { @@ -348,69 +206,6 @@ func Test_ServicePublisher_ExchangeDeclareFail(t *testing.T) { 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return errors.New("failed") - })(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())) - handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").get("key") - require.True(t, ok) - require.NotNil(t, handler) -} - -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", nil)) - - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return errors.New("failed") - })(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(ctx context.Context, msg ConsumableEvent[Message]) error { - return errors.New("failed") - })(conn) - - require.EqualError(t, err, expectedError) -} - func Test_PublishNotify(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) diff --git a/connection_test.go b/connection_test.go index 4c05bf0..3ee89be 100644 --- a/connection_test.go +++ b/connection_test.go @@ -355,24 +355,24 @@ 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"), }, } diff --git a/consumer.go b/consumer.go new file mode 100644 index 0000000..66cda22 --- /dev/null +++ b/consumer.go @@ -0,0 +1,102 @@ +package goamqp + +import ( + "fmt" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// 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 ...QueueBindingConfigSetup) 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]) Setup { + return func(c *Connection) error { + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + 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[T any](routingKey string, handler EventHandler[T]) Setup { + return func(c *Connection) error { + resExchangeName := serviceResponseExchangeName(c.serviceName) + if err := c.exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { + return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) + } + + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: serviceRequestQueueName(c.serviceName), + exchangeName: serviceRequestExchangeName(c.serviceName), + kind: kindDirect, + headers: amqp.Table{}, + } + + 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 ...QueueBindingConfigSetup) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + 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) + } +} + +// 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]) Setup { + return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) +} + +// 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]) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) + if err := c.addHandler(queueName, routingKey, newWrappedHandler(handler)); 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{}) + } +} diff --git a/consumer_test.go b/consumer_test.go new file mode 100644 index 0000000..8eedf0a --- /dev/null +++ b/consumer_test.go @@ -0,0 +1,190 @@ +package goamqp + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +func Test_EventStreamConsumer(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })) + 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })) + + 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })) + + 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })) + + 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_TransientEventStreamConsumer_Ok(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + uuid.SetRand(badRand{}) + err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") + })(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, noWait: false, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) + + require.Equal(t, 1, len(conn.queueHandlers.Queues())) + handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").get("key") + require.True(t, ok) + require.NotNil(t, handler) +} + +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", nil)) + + uuid.SetRand(badRand{}) + err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return errors.New("failed") + })(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(ctx context.Context, msg ConsumableEvent[Message]) error { + return errors.New("failed") + })(conn) + + require.EqualError(t, err, expectedError) +} + +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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { + return nil + })) + + require.ErrorContains(t, err, " failed, actual error message") +} diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index c3c3334..1484ce0 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -30,7 +30,7 @@ import ( ) // var amqpURL = "amqp://user:password@localhost:5672/test" -var amqpURL = "amqp://user:password@localhostl:5672/test" +var amqpURL = "amqp://user:password@localhost:5672/test" func Test_A(t *testing.T) { ctx := context.Background() diff --git a/go.mod b/go.mod index 6d420f6..c36c1bb 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.22.12 require ( github.com/google/uuid v1.6.0 - github.com/pkg/errors v0.9.1 github.com/rabbitmq/amqp091-go v1.10.0 github.com/stretchr/testify v1.10.0 go.opentelemetry.io/otel v1.22.0 diff --git a/go.sum b/go.sum index dfd3a66..aabb9a9 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,4 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -9,13 +10,22 @@ 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.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 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/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= +github.com/rabbitmq/amqp091-go v1.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= +github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 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= @@ -23,16 +33,11 @@ go.opentelemetry.io/otel/metric v1.22.0 h1:lypMQnGyJYeuYPhOM/bgjbFM6WE44W1/T45er go.opentelemetry.io/otel/metric v1.22.0/go.mod h1:evJGjVpZv0mQ5QBRJoBF64yMuOf4xCWdXjK8pzFvliY= 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= +go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= +go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -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/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/headers.go b/headers.go index 02d395f..8eb16a8 100644 --- a/headers.go +++ b/headers.go @@ -23,9 +23,9 @@ package goamqp import ( + "errors" "fmt" - "github.com/pkg/errors" amqp "github.com/rabbitmq/amqp091-go" ) diff --git a/publish.go b/publish.go index b8a005a..9b39149 100644 --- a/publish.go +++ b/publish.go @@ -68,6 +68,59 @@ func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) err return fmt.Errorf("%w %s", ErrNoRouteForMessageType, t) } +// 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 { + name := topicExchangeName(exchange) + return func(c *Connection) error { + if err := c.exchangeDeclare(c.channel, name, kindTopic); err != nil { + return fmt.Errorf("failed to declare exchange %s, %w", name, err) + } + 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 + } +} + +// 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 + } +} + func (p *Publisher) setDefaultHeaders(serviceName string, headers ...Header) error { for _, h := range headers { if err := h.validateKey(); err != nil { diff --git a/request_responae.go b/request_responae.go new file mode 100644 index 0000000..7554058 --- /dev/null +++ b/request_responae.go @@ -0,0 +1,14 @@ +package goamqp + +import "context" + +// 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) + } +} diff --git a/request_response_test.go b/request_response_test.go new file mode 100644 index 0000000..90fc997 --- /dev/null +++ b/request_response_test.go @@ -0,0 +1,35 @@ +package goamqp + +import ( + "context" + "reflect" + "runtime" + "testing" + + amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/require" +) + +func Test_RequestResponseHandler(t *testing.T) { + channel := NewMockAmqpChannel() + conn := mockConnection(channel) + err := RequestResponseHandler("key", func(ctx context.Context, msg ConsumableEvent[Message]) (response any, err error) { + return nil, 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: 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())) + + handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").get("key") + require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) +} From 83f565f55bc2bf3dbed7eaef394bbda15505a792 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 21:42:03 +0100 Subject: [PATCH 06/50] chore: wip # Conflicts: # connection.go --- consumer_test.go | 302 +++++++++------------- example_test.go | 4 +- examples/event-stream/example_test.go | 32 +-- examples/request-response/example_test.go | 20 +- 4 files changed, 154 insertions(+), 204 deletions(-) diff --git a/consumer_test.go b/consumer_test.go index 8eedf0a..660af46 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -10,181 +10,133 @@ import ( "github.com/stretchr/testify/require" ) -func Test_EventStreamConsumer(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := conn.Start(context.Background(), EventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - }, 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - - 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_TransientEventStreamConsumer_Ok(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return errors.New("failed") - })(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, noWait: false, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) - - require.Equal(t, 1, len(conn.queueHandlers.Queues())) - handler, ok := conn.queueHandlers.Handlers("events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f").get("key") - require.True(t, ok) - require.NotNil(t, handler) -} - -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", nil)) - - uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("root.#", func(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return errors.New("failed") - })(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) - +func Test_Setups(t *testing.T) { + // Needed for transient stream tests uuid.SetRand(badRand{}) - err := TransientEventStreamConsumer("key", func(ctx context.Context, msg ConsumableEvent[Message]) error { - return errors.New("failed") - })(conn) - - require.EqualError(t, err, expectedError) -} - -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(ctx context.Context, msg ConsumableEvent[TestMessage]) error { - return nil - })) - require.ErrorContains(t, err, " failed, actual error message") + tests := []struct { + name string + opts []Setup + expectedError string + expectedExchanges []ExchangeDeclaration + expectedQueues []QueueDeclaration + expectedBindings []BindingDeclaration + expectedConsumer []Consumer + expectedHandler *QueueHandlers + }{ + { + 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: amqp.Table{}}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, + 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: amqp.Table{}}}, + }, + { + 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: amqp.Table{}}}, + expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-suffix", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, + 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: amqp.Table{}}}, + }, + { + 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: amqp.Table{}}, + {name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, + }, + expectedQueues: []QueueDeclaration{{name: "svc.direct.exchange.request.queue", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, + 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: amqp.Table{}}}, + }, + { + 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: amqp.Table{}}}, + expectedQueues: []QueueDeclaration{{name: "targetService.headers.exchange.response.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, + 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: amqp.Table{}}}, + expectedHandler: &QueueHandlers{"targetService.headers.exchange.response.queue.svc": &Handlers{"key": func(ctx context.Context, event unmarshalEvent) error { + return 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: amqp.Table{}}}, + 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: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, + expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}}, + expectedHandler: &QueueHandlers{"events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f": &Handlers{"key": func(ctx context.Context, event unmarshalEvent) error { + return 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: amqp.Table{}}}, + 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: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, + 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) + } + // TODO require.Equal(t, tt.expectedHandler, conn.queueHandlers) + }) + } } diff --git a/example_test.go b/example_test.go index 942ed8b..4a84fd3 100644 --- a/example_test.go +++ b/example_test.go @@ -17,15 +17,13 @@ // 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/" diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 1484ce0..65436bd 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -26,7 +26,7 @@ import ( "testing" "time" - . "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/goamqp" ) // var amqpURL = "amqp://user:password@localhost:5672/test" @@ -37,12 +37,12 @@ func Test_A(t *testing.T) { 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), - WithTypeMapping("Order.Created", OrderCreated{}), - WithTypeMapping("Order.Updated", OrderUpdated{}), + goamqp.EventStreamPublisher(orderPublisher), + goamqp.WithTypeMapping("Order.Created", OrderCreated{}), + goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), ) checkError(err) @@ -65,7 +65,7 @@ func Test_A(t *testing.T) { // -- StatService type StatService struct { - connection *Connection + connection *goamqp.Connection } func (s *StatService) Stop() error { @@ -73,19 +73,19 @@ 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, - WithHandler("Order.Created", s.handleOrderCreated), - WithHandler("Order.Updated", s.handleOrderUpdated), + goamqp.WithHandler("Order.Created", s.handleOrderCreated), + goamqp.WithHandler("Order.Updated", s.handleOrderUpdated), ) } -func (s *StatService) handleOrderUpdated(ctx context.Context, msg ConsumableEvent[OrderUpdated]) error { +func (s *StatService) handleOrderUpdated(ctx context.Context, msg goamqp.ConsumableEvent[OrderUpdated]) error { fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) return nil } -func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEvent[OrderCreated]) error { +func (s *StatService) handleOrderCreated(ctx context.Context, msg goamqp.ConsumableEvent[OrderCreated]) error { // Just to make sure the Output is correct in the example... fmt.Printf("Created order, %s\n", msg.Payload.Id) return nil @@ -93,7 +93,7 @@ func (s *StatService) handleOrderCreated(ctx context.Context, msg ConsumableEven // -- ShippingService type ShippingService struct { - connection *Connection + connection *goamqp.Connection } func (s *ShippingService) Stop() error { @@ -101,11 +101,11 @@ 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, - WithTypeMapping("Order.Created", OrderCreated{}), - WithTypeMapping("Order.Updated", OrderUpdated{}), + goamqp.WithTypeMapping("Order.Created", OrderCreated{}), + goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), //WithHandler("#", s.connection.TypeMappingHandler(func(ctx context.Context, event any) (any, error) { // return s.handleOrderEvent(ctx, event) //}), diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index 192d963..ddcda83 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -25,7 +25,7 @@ import ( "os" "time" - . "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/goamqp" ) var amqpURL = "amqp://user:password@localhost:5672/test" @@ -36,19 +36,19 @@ 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), + 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, - WithTypeMapping(routingKey, Request{}), - ServicePublisher("service", publisher), - ServiceResponseConsumer("service", routingKey, handleResponse), + goamqp.WithTypeMapping(routingKey, Request{}), + goamqp.ServicePublisher("service", publisher), + goamqp.ServiceResponseConsumer("service", routingKey, handleResponse), ) checkError(err) @@ -70,13 +70,13 @@ func checkError(err error) { } } -func handleRequest(ctx context.Context, m ConsumableEvent[Request]) (any, error) { +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(ctx context.Context, m ConsumableEvent[Response]) error { +func handleResponse(ctx context.Context, m goamqp.ConsumableEvent[Response]) error { fmt.Printf("Got response, %v\n", m.Payload.Data) return nil } From ca20e64a1f0b75bc3e2930bf3194e6b047f993e3 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 23:53:18 +0100 Subject: [PATCH 07/50] chore: wip --- .pre-commit-config.yaml | 12 ++ channel.go | 54 ++++++ connection.go | 50 +---- connection_options_test.go | 136 -------------- connection_test.go | 14 +- consumer.go | 22 +++ consumer_test.go | 30 ++- doc.go | 25 +-- example_test.go | 25 +-- examples/event-stream/example_test.go | 25 +-- examples/request-response/example_test.go | 25 +-- handler.go | 147 +-------------- handler_internal.go | 156 ++++++++++++++++ metrics.go | 22 +++ must.go | 34 ++++ notifications.go | 22 +++ publish.go => publisher.go | 0 publisher_test.go | 214 ++++++++++++++++++++++ request_responae.go | 22 +++ request_response_test.go | 26 ++- 20 files changed, 684 insertions(+), 377 deletions(-) create mode 100644 channel.go create mode 100644 handler_internal.go create mode 100644 must.go rename publish.go => publisher.go (100%) create mode 100644 publisher_test.go diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a5ae938..0819896 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -24,3 +24,15 @@ 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: \_test.go$ + args: + - --license-filepath + - LICENSE # defaults to: LICENSE.txt + - --comment-style + - // # defaults to: # + - --use-current-year + - --no-extra-eol # see below diff --git a/channel.go b/channel.go new file mode 100644 index 0000000..56fd423 --- /dev/null +++ b/channel.go @@ -0,0 +1,54 @@ +// MIT License +// +// Copyright (c) 2024 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" +) + +// 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 + 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{} diff --git a/connection.go b/connection.go index 71c05b0..9e5848b 100644 --- a/connection.go +++ b/connection.go @@ -45,7 +45,7 @@ type Connection struct { connection amqpConnection // TODO One channel per queue/consumer channel AmqpChannel - queueHandlers *QueueHandlers + queueHandlers *queueHandlers typeToKey map[reflect.Type]string keyToType map[string]reflect.Type notificationCh chan<- Notification @@ -102,17 +102,6 @@ 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}) @@ -162,31 +151,6 @@ func (c *Connection) Close() error { return c.connection.Close() } -// 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 - 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) @@ -220,7 +184,6 @@ func responseWrapper[T, R any](handler RequestResponseEventHandler[T, R], routin if err != nil { return fmt.Errorf("failed to extract service name, %w", err) } - // TODO Handle response with type R err = publisher(ctx, service, routingKey, resp) if err != nil { return fmt.Errorf("failed to publish response, %w", err) @@ -314,7 +277,7 @@ func (c *Connection) exchangeDeclare(channel AmqpChannel, name string, kind kind } func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHandler) error { - return c.queueHandlers.Add(queueName, routingKey, handler) + return c.queueHandlers.add(queueName, routingKey, handler) } func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, exchangeName string, headers amqp.Table) error { @@ -347,7 +310,7 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { return deliveryInfo } -func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue QueueWithHandlers) { +func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue queueWithHandlers) { for delivery := range deliveries { startTime := time.Now() deliveryInfo := getDeliveryInfo(queue.Name, delivery) @@ -363,7 +326,6 @@ func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, qu uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} tracingCtx := extractToContext(delivery.Headers) - // TODO Handle response if err := handler(tracingCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) @@ -423,15 +385,15 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { return &Connection{ serviceName: serviceName, amqpUri: uri, - queueHandlers: &QueueHandlers{}, + queueHandlers: &queueHandlers{}, keyToType: make(map[string]reflect.Type), typeToKey: make(map[reflect.Type]string), } } func (c *Connection) setup() error { - for _, queue := range c.queueHandlers.Queues() { - // TODO one channel per queue` + for _, queue := range c.queueHandlers.queues() { + // TODO one channel per queue consumer, err := consume(c.channel, queue.Name) if err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", queue.Name, err) diff --git a/connection_options_test.go b/connection_options_test.go index c346c98..d05e08f 100644 --- a/connection_options_test.go +++ b/connection_options_test.go @@ -23,9 +23,7 @@ package goamqp import ( - "context" "errors" - "reflect" "testing" amqp "github.com/rabbitmq/amqp091-go" @@ -47,66 +45,6 @@ func Test_CloseListener(t *testing.T) { require.EqualError(t, err, "Exception (123) Reason: \"Close reason\"") } -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.Publish(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - - published := <-channel.Published - require.Equal(t, "key", published.key) - - err = p.Publish(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_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.Publish(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - - published := <-channel.Published - require.Equal(t, "key", published.key) - - err = p.Publish(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_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) @@ -119,80 +57,6 @@ func Test_EventStreamPublisher_FailedToCreateExchange(t *testing.T) { require.EqualError(t, err, "failed to declare exchange events.topic.exchange, failed to create exchange") } -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.Publish(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.Publish(context.Background(), TestMessage{"test", true}) - require.NoError(t, err) - err = p.Publish(context.Background(), TestMessage2{Msg: "msg"}) - require.NoError(t, err) - err = p.Publish(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.Publish(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() diff --git a/connection_test.go b/connection_test.go index 3ee89be..f29465d 100644 --- a/connection_test.go +++ b/connection_test.go @@ -96,7 +96,7 @@ func Test_Start_SetupFails(t *testing.T) { serviceName: "test", connection: mockAmqpConnection, channel: mockChannel, - queueHandlers: &QueueHandlers{}, + queueHandlers: &queueHandlers{}, } err := conn.Start(context.Background(), EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) error { @@ -410,15 +410,15 @@ func Test_DivertToMessageHandler(t *testing.T) { } channel := MockAmqpChannel{Published: make(chan Publish, 1)} - handlers := QueueHandlers{} + handlers := queueHandlers{} handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { if msg.Payload.Ok { return nil } return errors.New("failed") }) - require.NoError(t, handlers.Add("q", "key1", handler)) - require.NoError(t, handlers.Add("q", "key2", handler)) + require.NoError(t, handlers.add("q", "key1", handler)) + require.NoError(t, handlers.add("q", "key2", handler)) queueDeliveries := make(chan amqp.Delivery, 6) @@ -432,7 +432,7 @@ func Test_DivertToMessageHandler(t *testing.T) { started: true, channel: &channel, } - c.divertToMessageHandlers(queueDeliveries, handlers.Queues()[0]) + c.divertToMessageHandlers(queueDeliveries, handlers.queues()[0]) require.Equal(t, 1, len(acker.Rejects)) require.Equal(t, 1, len(acker.Nacks)) @@ -490,9 +490,9 @@ func testHandleMessage(json string, handle bool) MockAcknowledger { } c := &Connection{} deliveries := make(chan amqp.Delivery) - queue := QueueWithHandlers{ + queue := queueWithHandlers{ Name: "", - Handlers: &Handlers{ + Handlers: &handlers{ "key": newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { if handle { return nil diff --git a/consumer.go b/consumer.go index 66cda22..1b1672c 100644 --- a/consumer.go +++ b/consumer.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( diff --git a/consumer_test.go b/consumer_test.go index 660af46..5335f4e 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( @@ -10,7 +32,7 @@ import ( "github.com/stretchr/testify/require" ) -func Test_Setups(t *testing.T) { +func Test_Consumer_Setups(t *testing.T) { // Needed for transient stream tests uuid.SetRand(badRand{}) @@ -22,7 +44,7 @@ func Test_Setups(t *testing.T) { expectedQueues []QueueDeclaration expectedBindings []BindingDeclaration expectedConsumer []Consumer - expectedHandler *QueueHandlers + expectedHandler *queueHandlers }{ { name: "EventStreamConsumer", @@ -73,7 +95,7 @@ func Test_Setups(t *testing.T) { expectedQueues: []QueueDeclaration{{name: "targetService.headers.exchange.response.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, 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: amqp.Table{}}}, - expectedHandler: &QueueHandlers{"targetService.headers.exchange.response.queue.svc": &Handlers{"key": func(ctx context.Context, event unmarshalEvent) error { + expectedHandler: &queueHandlers{"targetService.headers.exchange.response.queue.svc": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { return nil }}}, }, @@ -86,7 +108,7 @@ func Test_Setups(t *testing.T) { 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: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}}, - expectedHandler: &QueueHandlers{"events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f": &Handlers{"key": func(ctx context.Context, event unmarshalEvent) error { + expectedHandler: &queueHandlers{"events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { return nil }}}, }, diff --git a/doc.go b/doc.go index 895e206..2bf99fa 100644 --- a/doc.go +++ b/doc.go @@ -1,21 +1,24 @@ +// MIT License +// // Copyright (c) 2024 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: +// 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/example_test.go b/example_test.go index 4a84fd3..dbbffff 100644 --- a/example_test.go +++ b/example_test.go @@ -1,21 +1,24 @@ +// MIT License +// // Copyright (c) 2024 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: +// 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 diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 65436bd..ab22cdb 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -1,21 +1,24 @@ +// MIT License +// // Copyright (c) 2024 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: +// 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 diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index ddcda83..d649e1a 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -1,21 +1,24 @@ +// MIT License +// // Copyright (c) 2024 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: +// 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 diff --git a/handler.go b/handler.go index b5bf5dd..6fe06c3 100644 --- a/handler.go +++ b/handler.go @@ -22,147 +22,14 @@ package goamqp -import ( - "context" - "encoding/json" - "errors" - "fmt" - "regexp" - "strings" -) - -// type Handler func(ctx context.Context, event any) error - -// EventHandler is the type definition for a function that is used to handle events of a specific type. -// TODO 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 +import "context" type ( - EventHandler[T any] func(ctx context.Context, event ConsumableEvent[T]) error + // EventHandler is the type definition for 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 the type definition for 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) ) - -// Handlers holds the handlers for a certain queue -type Handlers map[string]wrappedHandler - -// get returns the handler for the given queue and routing key that matches -func (h *Handlers) 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 matches function to support wildcards) -func (h *Handlers) exists(routingKey string) (string, bool) { - for mappedRoutingKey := range *h { - if overlaps(routingKey, mappedRoutingKey) { - return mappedRoutingKey, true - } - } - return "", false -} - -func (h *Handlers) add(routingKey string, handler wrappedHandler) { - (*h)[routingKey] = handler -} - -// QueueHandlers holds all handlers for all queues -type QueueHandlers map[string]*Handlers - -// Add a handler for the given queue and routing key -func (h *QueueHandlers) Add(queueName, routingKey string, handler wrappedHandler) error { - queueHandlers, ok := (*h)[queueName] - if !ok { - queueHandlers = &Handlers{} - (*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 QueueWithHandlers struct { - Name string - Handlers *Handlers -} - -// Queues returns all queue names for which we have added a handler -func (h *QueueHandlers) Queues() []QueueWithHandlers { - if h == nil { - return []QueueWithHandlers{} - } - var res []QueueWithHandlers - for q, h := range *h { - res = append(res, QueueWithHandlers{Name: q, Handlers: h}) - } - return res -} - -// Handlers returns all the handlers for a given queue, keyed by the routing key -func (h *QueueHandlers) Handlers(queueName string) *Handlers { - if h == nil { - return &Handlers{} - } - - if handlers, ok := (*h)[queueName]; ok { - return handlers - } - return &Handlers{} -} - -// 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 { - 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) - } -} - -var ErrParseJSON = errors.New("failed to parse") - -// overlaps checks if two AMQP binding patterns overlap -func overlaps(p1, p2 string) bool { - if p1 == p2 { - return true - } else if match(p1, p2) { - return true - } else if match(p2, p1) { - return true - } - return false -} - -// match returns true if the AMQP binding pattern is matching the routing key -func match(pattern string, routingKey string) bool { - b, err := regexp.MatchString(fixRegex(pattern), routingKey) - if err != nil { - return false - } - return b -} - -// fixRegex converts the AMQP binding key syntax to regular expression -// For example: -// user.* => user\.[^.]* -// user.# => user\..* -func fixRegex(s string) string { - replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) - return fmt.Sprintf("^%s$", replace) -} diff --git a/handler_internal.go b/handler_internal.go new file mode 100644 index 0000000..91bec93 --- /dev/null +++ b/handler_internal.go @@ -0,0 +1,156 @@ +// MIT License +// +// Copyright (c) 2024 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" + "regexp" + "strings" +) + +// handlers holds the handlers for a certain queue +type handlers map[string]wrappedHandler + +// get returns the handler for the given queue and routing key that matches +func (h *handlers) 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 matches function to support wildcards) +func (h *handlers) exists(routingKey string) (string, bool) { + for mappedRoutingKey := range *h { + if overlaps(routingKey, mappedRoutingKey) { + return mappedRoutingKey, true + } + } + return "", false +} + +func (h *handlers) add(routingKey string, handler wrappedHandler) { + (*h)[routingKey] = handler +} + +// queueHandlers holds all handlers for all queues +type queueHandlers map[string]*handlers + +// add a handler for the given queue and routing key +func (h *queueHandlers) add(queueName, routingKey string, handler wrappedHandler) error { + queueHandlers, ok := (*h)[queueName] + if !ok { + queueHandlers = &handlers{} + (*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 queueWithHandlers struct { + Name string + Handlers *handlers +} + +// queues returns all queue names for which we have added a handler +func (h *queueHandlers) queues() []queueWithHandlers { + if h == nil { + return []queueWithHandlers{} + } + var res []queueWithHandlers + for q, h := range *h { + res = append(res, queueWithHandlers{Name: q, Handlers: h}) + } + return res +} + +// handlers returns all the handlers for a given queue, keyed by the routing key +func (h *queueHandlers) handlers(queueName string) *handlers { + if h == nil { + return &handlers{} + } + + if handlers, ok := (*h)[queueName]; ok { + return handlers + } + return &handlers{} +} + +// 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 { + 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) + } +} + +var ErrParseJSON = errors.New("failed to parse") + +// overlaps checks if two AMQP binding patterns overlap +func overlaps(p1, p2 string) bool { + if p1 == p2 { + return true + } else if match(p1, p2) { + return true + } else if match(p2, p1) { + return true + } + return false +} + +// match returns true if the AMQP binding pattern is matching the routing key +func match(pattern string, routingKey string) bool { + b, err := regexp.MatchString(fixRegex(pattern), routingKey) + if err != nil { + return false + } + return b +} + +// fixRegex converts the AMQP binding key syntax to regular expression +// For example: +// user.* => user\.[^.]* +// user.# => user\..* +func fixRegex(s string) string { + replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) + return fmt.Sprintf("^%s$", replace) +} diff --git a/metrics.go b/metrics.go index a2baf8d..732fe12 100644 --- a/metrics.go +++ b/metrics.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 func EventReceived(queue string, routingKey string) { diff --git a/must.go b/must.go new file mode 100644 index 0000000..42ac4ad --- /dev/null +++ b/must.go @@ -0,0 +1,34 @@ +// MIT License +// +// Copyright (c) 2024 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/notifications.go b/notifications.go index 0c6919a..0a092e2 100644 --- a/notifications.go +++ b/notifications.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 Notification struct { diff --git a/publish.go b/publisher.go similarity index 100% rename from publish.go rename to publisher.go diff --git a/publisher_test.go b/publisher_test.go new file mode 100644 index 0000000..2a0e283 --- /dev/null +++ b/publisher_test.go @@ -0,0 +1,214 @@ +// MIT License +// +// Copyright (c) 2024 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" + "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 []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"), WithTypeMapping("key", TestMessage{})} + }, + messages: []any{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), WithTypeMapping("key", TestMessage{})} + }, + messages: []any{TestMessage{"test", true}}, + headers: []Header{{"x-header", "header"}}, + expectedExchanges: []ExchangeDeclaration{{name: topicExchangeName(defaultEventExchangeName), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindTopic, args: amqp.Table{}}}, + 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), WithTypeMapping("key", TestMessage{})} + }, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + messages: []any{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), WithTypeMapping("key1", TestMessage{}), WithTypeMapping("key2", TestMessage2{})} + }, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + messages: []any{ + TestMessage{"test", true}, + TestMessage2{"test", false}, + TestMessage{"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, + }, + }, { + exchange: serviceRequestExchangeName("svc"), + key: "key1", + mandatory: false, + immediate: false, + msg: amqp.Publishing{ + Headers: amqp.Table{"service": "svc"}, + ContentType: contentType, + ContentEncoding: "", + DeliveryMode: 2, + }, + }, + }, + }, + { + name: "no route", + opts: func(p *Publisher) []Setup { + return []Setup{ServicePublisher("svc", p)} + }, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + messages: []any{ + TestMessage{"test", true}, + }, + expectedError: "no routingkey configured for message of type goamqp.TestMessage", + }, + } + 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) + } + + for i, msg := range tt.messages { + err := p.Publish(ctx, 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!") + } + } + }) + } +} diff --git a/request_responae.go b/request_responae.go index 7554058..6f34ba8 100644 --- a/request_responae.go +++ b/request_responae.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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" diff --git a/request_response_test.go b/request_response_test.go index 90fc997..58874ca 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( @@ -28,8 +50,8 @@ func Test_RequestResponseHandler(t *testing.T) { 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())) + require.Equal(t, 1, len(conn.queueHandlers.queues())) - handler, _ := conn.queueHandlers.Handlers("svc.direct.exchange.request.queue").get("key") + handler, _ := conn.queueHandlers.handlers("svc.direct.exchange.request.queue").get("key") require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) } From a28e719f99bc889519edc28c1fa37c7a1880f631 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Mon, 5 Feb 2024 23:54:39 +0100 Subject: [PATCH 08/50] chore: wip --- connection_options.go => setup.go | 0 connection_options_test.go => setup_test.go | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename connection_options.go => setup.go (100%) rename connection_options_test.go => setup_test.go (100%) diff --git a/connection_options.go b/setup.go similarity index 100% rename from connection_options.go rename to setup.go diff --git a/connection_options_test.go b/setup_test.go similarity index 100% rename from connection_options_test.go rename to setup_test.go From 37552defd94afcbb37677c4c015f507adb824649 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 6 Feb 2024 00:48:12 +0100 Subject: [PATCH 09/50] chore: wip, integration test --- connection.go | 8 ++- examples/event-stream/example_test.go | 1 + go.mod | 4 +- go.sum | 4 ++ {_integration => integration}/amqp_admin.go | 2 +- .../integration_test.go | 24 +------ integration/messages.go | 13 ++++ integration/tracing_test.go | 65 +++++++++++++++++++ 8 files changed, 97 insertions(+), 24 deletions(-) rename {_integration => integration}/amqp_admin.go (99%) rename {_integration => integration}/integration_test.go (97%) create mode 100644 integration/messages.go create mode 100644 integration/tracing_test.go diff --git a/connection.go b/connection.go index 9e5848b..07363c6 100644 --- a/connection.go +++ b/connection.go @@ -292,12 +292,18 @@ func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, ex DeliveryMode: 2, Headers: injectToHeaders(ctx, headers), } - return c.channel.PublishWithContext(ctx, exchangeName, + err = c.channel.PublishWithContext(ctx, exchangeName, routingKey, false, false, publishing, ) + if err != nil { + EventPublishFailed(exchangeName, routingKey) + return err + } + EventPublishSucceed(exchangeName, routingKey) + return nil } func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index ab22cdb..99cc8f3 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -84,6 +84,7 @@ func (s *StatService) Start(ctx context.Context) error { } func (s *StatService) handleOrderUpdated(ctx context.Context, msg goamqp.ConsumableEvent[OrderUpdated]) error { + // Just to make sure the Output is correct in the example... fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) return nil } diff --git a/go.mod b/go.mod index c36c1bb..e1d9f61 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( 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 ) require ( @@ -15,6 +17,6 @@ require ( github.com/go-logr/stdr v1.2.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.opentelemetry.io/otel/metric v1.22.0 // indirect - go.opentelemetry.io/otel/trace v1.22.0 // indirect + golang.org/x/sys v0.16.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index aabb9a9..9ee1ffc 100644 --- a/go.sum +++ b/go.sum @@ -31,10 +31,14 @@ 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.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= +golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/_integration/amqp_admin.go b/integration/amqp_admin.go similarity index 99% rename from _integration/amqp_admin.go rename to integration/amqp_admin.go index b7a8e8d..f71147c 100644 --- a/_integration/amqp_admin.go +++ b/integration/amqp_admin.go @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package _integration +package integration import ( "encoding/json" diff --git a/_integration/integration_test.go b/integration/integration_test.go similarity index 97% rename from _integration/integration_test.go rename to integration/integration_test.go index f26b946..c48100d 100644 --- a/_integration/integration_test.go +++ b/integration/integration_test.go @@ -20,7 +20,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package _integration +package integration import ( "context" @@ -30,19 +30,12 @@ import ( "testing" "time" + . "github.com/sparetimecoders/goamqp" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" - - . "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/test" ) @@ -524,6 +517,7 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { // 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) @@ -589,15 +583,3 @@ 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/integration/messages.go b/integration/messages.go new file mode 100644 index 0000000..b05c136 --- /dev/null +++ b/integration/messages.go @@ -0,0 +1,13 @@ +package integration + +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..7261299 --- /dev/null +++ b/integration/tracing_test.go @@ -0,0 +1,65 @@ +// MIT License +// +// Copyright (c) 2024 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.Equal(suite.T(), trace.SpanFromContext(publishingContext).SpanContext().TraceID(), actualTraceID) + require.NoError(suite.T(), server.Close()) + // TODO stop consumer go processes + // goleak.VerifyNone(suite.T()) +} From 74b12a88a634d62f3a1df655f0ee395c02f4b8dd Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 6 Feb 2024 00:56:44 +0100 Subject: [PATCH 10/50] chore: integration test --- .github/workflows/tests.yml | 2 +- go.mod | 1 + go.sum | 3 ++- integration/amqp_admin.go | 29 ++++++++++++++++++++--------- integration/integration_test.go | 23 ++++++++++------------- integration/messages.go | 5 ++++- integration/tracing_test.go | 9 +++++---- 7 files changed, 43 insertions(+), 29 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2be85a7..3d806a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,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/go.mod b/go.mod index e1d9f61..2639e0b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( 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 ( diff --git a/go.sum b/go.sum index 9ee1ffc..6a90e02 100644 --- a/go.sum +++ b/go.sum @@ -35,8 +35,9 @@ go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB 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.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/integration/amqp_admin.go b/integration/amqp_admin.go index f71147c..6376a1f 100644 --- a/integration/amqp_admin.go +++ b/integration/amqp_admin.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + // MIT License // // Copyright (c) 2024 sparetimecoders @@ -20,13 +23,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package integration +package _integration import ( "encoding/json" "fmt" "io" - "log" "net/http" "reflect" "regexp" @@ -45,25 +47,31 @@ type amqpAdmin struct { Port int } -func ParseAmqpURL(amqpURL string) *amqpAdmin { - amqpConnectionRegex := regexp.MustCompile(`(?:amqp:\/\/)?(?P.*):(?P.*?)@(?P.*?)(?:\:(?P\d*))?(?:\/(?P.*))?$`) +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 + return a, a.createVHost() } - log.Panicf("invalid Amqp URL: %s", amqpURL) - return nil + + 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 { +func (a *amqpAdmin) createVHost() error { _, err := a.request(http.MethodPut, fmt.Sprintf("/vhosts/%s", a.VHost), nil) return err } -func (a *amqpAdmin) DeleteVHost() error { +func (a *amqpAdmin) deleteVHost() error { _, err := a.request(http.MethodDelete, fmt.Sprintf("/vhosts/%s", a.VHost), nil) return err } @@ -84,6 +92,7 @@ func (a *amqpAdmin) GetExchanges(filterDefaults bool) ([]Exchange, error) { if err != nil { return nil, err } + defer resp.Body.Close() var exchanges []Exchange err = json.NewDecoder(resp.Body).Decode(&exchanges) if filterDefaults { @@ -114,6 +123,7 @@ func (a *amqpAdmin) GetQueues() ([]Queue, error) { if err != nil { return nil, err } + defer resp.Body.Close() var queues []Queue err = json.NewDecoder(resp.Body).Decode(&queues) return queues, err @@ -124,6 +134,7 @@ func (a *amqpAdmin) GetBindings(queueName string, filterDefault bool) ([]Binding if err != nil { return nil, err } + defer resp.Body.Close() var bindings []Binding err = json.NewDecoder(resp.Body).Decode(&bindings) if filterDefault { diff --git a/integration/integration_test.go b/integration/integration_test.go index c48100d..465d57d 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + // MIT License // // Copyright (c) 2024 sparetimecoders @@ -20,7 +23,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. -package integration +package _integration import ( "context" @@ -33,6 +36,7 @@ import ( . "github.com/sparetimecoders/goamqp" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + "go.uber.org/goleak" ) var ( @@ -49,15 +53,16 @@ func (suite *IntegrationTestSuite) SetupTest() { if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { amqpURL = urlFromEnv } - suite.admin = ParseAmqpURL(amqpURL) - require.NotNil(suite.T(), suite.admin) - err := suite.admin.CreateVHost() + 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) { @@ -260,7 +265,6 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { err := publish.Publish(context.Background(), &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer require.Equal(suite.T(), Incoming{Query: clientQuery}, client1Received) @@ -369,7 +373,6 @@ func (suite *IntegrationTestSuite) Test_EventStream() { err = publish.Publish(context.Background(), &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer require.Equal(suite.T(), Incoming{Query: clientQuery}, received[0]) @@ -495,7 +498,6 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { err = publish.Publish(context.Background(), &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) - go forceClose(closer, 3) <-closer <-closer <-closer @@ -578,8 +580,3 @@ func createConnection(suite *IntegrationTestSuite, serviceName string, opts ...S require.NoError(suite.T(), err) return conn } - -func forceClose(closer chan bool, seconds int64) { - time.Sleep(time.Duration(seconds) * time.Second) - closer <- true -} diff --git a/integration/messages.go b/integration/messages.go index b05c136..0989ab0 100644 --- a/integration/messages.go +++ b/integration/messages.go @@ -1,4 +1,7 @@ -package integration +//go:build integration +// +build integration + +package _integration type Incoming struct { Query string diff --git a/integration/tracing_test.go b/integration/tracing_test.go index 7261299..618fdea 100644 --- a/integration/tracing_test.go +++ b/integration/tracing_test.go @@ -1,3 +1,6 @@ +//go:build integration +// +build integration + // MIT License // // Copyright (c) 2024 sparetimecoders @@ -21,7 +24,7 @@ // SOFTWARE. // MIT License -package integration +package _integration import ( "context" @@ -58,8 +61,6 @@ func (suite *IntegrationTestSuite) Test_Tracing() { require.NoError(suite.T(), err) <-closer - require.Equal(suite.T(), trace.SpanFromContext(publishingContext).SpanContext().TraceID(), actualTraceID) require.NoError(suite.T(), server.Close()) - // TODO stop consumer go processes - // goleak.VerifyNone(suite.T()) + require.Equal(suite.T(), trace.SpanFromContext(publishingContext).SpanContext().TraceID(), actualTraceID) } From 0f2e84888d833d309428bd455ade58875b2b9041 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 6 Feb 2024 10:34:20 +0100 Subject: [PATCH 11/50] chore: handler for type mapping --- connection.go | 15 ++- consumer.go | 52 +++++++++ examples/event-stream/example_test.go | 47 ++++---- handler.go | 137 ++++++++++++++++++++-- handler_internal.go | 156 -------------------------- publisher.go | 7 +- setup.go | 46 +------- 7 files changed, 221 insertions(+), 239 deletions(-) delete mode 100644 handler_internal.go diff --git a/connection.go b/connection.go index 07363c6..05ddd89 100644 --- a/connection.go +++ b/connection.go @@ -46,8 +46,8 @@ type Connection struct { // TODO One channel per queue/consumer channel AmqpChannel queueHandlers *queueHandlers - typeToKey map[reflect.Type]string - keyToType map[string]reflect.Type + typeToKey TypeToRoutingKey + keyToType RoutingKeyToType notificationCh chan<- Notification } @@ -318,6 +318,8 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue queueWithHandlers) { for delivery := range deliveries { + // TODO Copy readonly to context, write test! + handlerCtx := injectRoutingKeyToTypeContext(extractToContext(delivery.Headers), c.keyToType) startTime := time.Now() deliveryInfo := getDeliveryInfo(queue.Name, delivery) EventReceived(queue.Name, deliveryInfo.RoutingKey) @@ -325,19 +327,21 @@ func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, qu // Establish which handler is invoked handler, ok := queue.Handlers.get(deliveryInfo.RoutingKey) if !ok { - _ = delivery.Reject(false) EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) + _ = delivery.Reject(false) continue } uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} - tracingCtx := extractToContext(delivery.Headers) - if err := handler(tracingCtx, uevt); err != nil { + if err := handler(handlerCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) if errors.Is(err, ErrParseJSON) { EventNotParsable(queue.Name, deliveryInfo.RoutingKey) _ = delivery.Nack(false, false) + } else if errors.Is(err, ErrNoMessageTypeForRouteKey) { + EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) + _ = delivery.Reject(false) } else { _ = delivery.Nack(false, true) } @@ -349,7 +353,6 @@ func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, qu notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) _ = delivery.Ack(false) EventAck(queue.Name, deliveryInfo.RoutingKey, elapsed) - } } diff --git a/consumer.go b/consumer.go index 1b1672c..b10da1b 100644 --- a/consumer.go +++ b/consumer.go @@ -23,11 +23,63 @@ package goamqp import ( + "context" + "encoding/json" "fmt" + "reflect" amqp "github.com/rabbitmq/amqp091-go" ) +type ( + // Handler is the type definition for a function that is used to handle events that has been mapped with + // RoutingKey <-> Type mappings from WithTypeMapping. + // If processing fails, an error should be returned and the message will be re-queued + Handler func(ctx context.Context, event any) error + // EventHandler is the type definition for 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 the type definition for 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) +) + +func WithTypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { + return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { + message, exists := routingKeyToTypeFromContext(ctx, event) + if !exists { + return ErrNoMessageTypeForRouteKey + } + if err := json.Unmarshal(event.Payload, &message); err != nil { + return err + } + return handler(ctx, message) + } +} + +type routingKeyToTypeCtx string + +const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" + +func injectRoutingKeyToTypeContext(ctx context.Context, keyToType RoutingKeyToType) context.Context { + return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) +} + +func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { + routingKey := event.DeliveryInfo.RoutingKey + keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(RoutingKeyToType) + if !ok { + return nil, false + } + + typ, exists := keyToType[routingKey] + if !exists { + return nil, false + } + return reflect.New(typ).Interface(), true +} + // 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 ...QueueBindingConfigSetup) Setup { diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 99cc8f3..82fa8ec 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -26,7 +26,6 @@ import ( "context" "fmt" "os" - "testing" "time" "github.com/sparetimecoders/goamqp" @@ -35,7 +34,7 @@ import ( // var amqpURL = "amqp://user:password@localhost:5672/test" var amqpURL = "amqp://user:password@localhost:5672/test" -func Test_A(t *testing.T) { +func ExampleEventStream() { ctx := context.Background() if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { amqpURL = urlFromEnv @@ -64,11 +63,18 @@ func Test_A(t *testing.T) { time.Sleep(2 * time.Second) _ = orderServiceConnection.Close() _ = 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 *goamqp.Connection + output []string } func (s *StatService) Stop() error { @@ -78,26 +84,25 @@ func (s *StatService) Stop() error { func (s *StatService) Start(ctx context.Context) error { s.connection = goamqp.Must(goamqp.NewFromURL("stat-service", amqpURL)) return s.connection.Start(ctx, - goamqp.WithHandler("Order.Created", s.handleOrderCreated), - goamqp.WithHandler("Order.Updated", s.handleOrderUpdated), + goamqp.EventStreamConsumer("Order.Created", s.handleOrderCreated), + goamqp.EventStreamConsumer("Order.Updated", s.handleOrderUpdated), ) } func (s *StatService) handleOrderUpdated(ctx context.Context, msg goamqp.ConsumableEvent[OrderUpdated]) error { - // Just to make sure the Output is correct in the example... - fmt.Printf("Updated order id, %s - %s\n", msg.Payload.Id, msg.Payload.Data) + 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 { - // Just to make sure the Output is correct in the example... - fmt.Printf("Created order, %s\n", msg.Payload.Id) + s.output = append(s.output, fmt.Sprintf("Created order: %s", msg.Payload.Id)) return nil } // -- ShippingService type ShippingService struct { connection *goamqp.Connection + output []string } func (s *ShippingService) Stop() error { @@ -110,25 +115,19 @@ func (s *ShippingService) Start(ctx context.Context) error { return s.connection.Start(ctx, goamqp.WithTypeMapping("Order.Created", OrderCreated{}), goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), - //WithHandler("#", s.connection.TypeMappingHandler(func(ctx context.Context, event any) (any, error) { - // return s.handleOrderEvent(ctx, event) - //}), - //) + goamqp.EventStreamConsumer("#", goamqp.WithTypeMappingHandler(func(ctx context.Context, event any) error { + switch event.(type) { + case *OrderCreated: + s.output = append(s.output, "Order created") + case *OrderUpdated: + s.output = append(s.output, "Order deleted") + } + return nil + }), + ), ) } -func (s *ShippingService) handleOrderEvent(ctx context.Context, msg any) (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 -} - func checkError(err error) { if err != nil { panic(err) diff --git a/handler.go b/handler.go index 6fe06c3..a8b9d81 100644 --- a/handler.go +++ b/handler.go @@ -22,14 +22,131 @@ package goamqp -import "context" - -type ( - // EventHandler is the type definition for 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 the type definition for 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) +import ( + "context" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" ) + +// handlers holds the handlers for a certain queue +type handlers map[string]wrappedHandler + +// get returns the handler for the given queue and routing key that matches +func (h *handlers) 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 matches function to support wildcards) +func (h *handlers) exists(routingKey string) (string, bool) { + for mappedRoutingKey := range *h { + if overlaps(routingKey, mappedRoutingKey) { + return mappedRoutingKey, true + } + } + return "", false +} + +func (h *handlers) add(routingKey string, handler wrappedHandler) { + (*h)[routingKey] = handler +} + +// queueHandlers holds all handlers for all queues +type queueHandlers map[string]*handlers + +// add a handler for the given queue and routing key +func (h *queueHandlers) add(queueName, routingKey string, handler wrappedHandler) error { + queueHandlers, ok := (*h)[queueName] + if !ok { + queueHandlers = &handlers{} + (*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 queueWithHandlers struct { + Name string + Handlers *handlers +} + +// queues returns all queue names for which we have added a handler +func (h *queueHandlers) queues() []queueWithHandlers { + if h == nil { + return []queueWithHandlers{} + } + var res []queueWithHandlers + for q, h := range *h { + res = append(res, queueWithHandlers{Name: q, Handlers: h}) + } + return res +} + +// handlers returns all the handlers for a given queue, keyed by the routing key +func (h *queueHandlers) handlers(queueName string) *handlers { + if handlers, ok := (*h)[queueName]; ok { + return handlers + } + return &handlers{} +} + +// 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 { + 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) + } +} + +var ErrParseJSON = errors.New("failed to parse") + +// overlaps checks if two AMQP binding patterns overlap +func overlaps(p1, p2 string) bool { + if p1 == p2 { + return true + } else if match(p1, p2) { + return true + } else if match(p2, p1) { + return true + } + return false +} + +// match returns true if the AMQP binding pattern is matching the routing key +func match(pattern string, routingKey string) bool { + b, err := regexp.MatchString(fixRegex(pattern), routingKey) + if err != nil { + return false + } + return b +} + +// fixRegex converts the AMQP binding key syntax to regular expression +// For example: +// user.* => user\.[^.]* +// user.# => user\..* +func fixRegex(s string) string { + replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) + return fmt.Sprintf("^%s$", replace) +} diff --git a/handler_internal.go b/handler_internal.go deleted file mode 100644 index 91bec93..0000000 --- a/handler_internal.go +++ /dev/null @@ -1,156 +0,0 @@ -// MIT License -// -// Copyright (c) 2024 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" - "regexp" - "strings" -) - -// handlers holds the handlers for a certain queue -type handlers map[string]wrappedHandler - -// get returns the handler for the given queue and routing key that matches -func (h *handlers) 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 matches function to support wildcards) -func (h *handlers) exists(routingKey string) (string, bool) { - for mappedRoutingKey := range *h { - if overlaps(routingKey, mappedRoutingKey) { - return mappedRoutingKey, true - } - } - return "", false -} - -func (h *handlers) add(routingKey string, handler wrappedHandler) { - (*h)[routingKey] = handler -} - -// queueHandlers holds all handlers for all queues -type queueHandlers map[string]*handlers - -// add a handler for the given queue and routing key -func (h *queueHandlers) add(queueName, routingKey string, handler wrappedHandler) error { - queueHandlers, ok := (*h)[queueName] - if !ok { - queueHandlers = &handlers{} - (*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 queueWithHandlers struct { - Name string - Handlers *handlers -} - -// queues returns all queue names for which we have added a handler -func (h *queueHandlers) queues() []queueWithHandlers { - if h == nil { - return []queueWithHandlers{} - } - var res []queueWithHandlers - for q, h := range *h { - res = append(res, queueWithHandlers{Name: q, Handlers: h}) - } - return res -} - -// handlers returns all the handlers for a given queue, keyed by the routing key -func (h *queueHandlers) handlers(queueName string) *handlers { - if h == nil { - return &handlers{} - } - - if handlers, ok := (*h)[queueName]; ok { - return handlers - } - return &handlers{} -} - -// 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 { - 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) - } -} - -var ErrParseJSON = errors.New("failed to parse") - -// overlaps checks if two AMQP binding patterns overlap -func overlaps(p1, p2 string) bool { - if p1 == p2 { - return true - } else if match(p1, p2) { - return true - } else if match(p2, p1) { - return true - } - return false -} - -// match returns true if the AMQP binding pattern is matching the routing key -func match(pattern string, routingKey string) bool { - b, err := regexp.MatchString(fixRegex(pattern), routingKey) - if err != nil { - return false - } - return b -} - -// fixRegex converts the AMQP binding key syntax to regular expression -// For example: -// user.* => user\.[^.]* -// user.# => user\..* -func fixRegex(s string) string { - replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) - return fmt.Sprintf("^%s$", replace) -} diff --git a/publisher.go b/publisher.go index 9b39149..36c0b4f 100644 --- a/publisher.go +++ b/publisher.go @@ -38,13 +38,18 @@ type Publisher struct { } // ErrNoRouteForMessageType when the published message cannot be routed. -var ErrNoRouteForMessageType = fmt.Errorf("no routingkey configured for message of type") +var ( + ErrNoRouteForMessageType = fmt.Errorf("no routingkey configured for message of type") + 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{} } +// Publish tries to publish msg to AMQP. +// It requires RoutingKey <-> Type mappings from WithTypeMapping in order to set the correct Routing Key for msg func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) error { table := amqp.Table{} for _, v := range p.defaultHeaders { diff --git a/setup.go b/setup.go index 1635d47..15d6bda 100644 --- a/setup.go +++ b/setup.go @@ -30,6 +30,10 @@ import ( amqp "github.com/rabbitmq/amqp091-go" ) +type RoutingKeyToType map[string]reflect.Type + +type TypeToRoutingKey map[reflect.Type]string + // 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 @@ -50,48 +54,6 @@ func WithTypeMapping(routingKey string, msgType any) Setup { } } -//func WithTypeMappingHandler(handler Handler) Setup { -// return func(c *Connection) error { -// return nil -// } -//} -/* - return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { - routingKey := event.DeliveryInfo.RoutingKey - typ, exists := c.keyToType[routingKey] - if !exists { - return nil - } - message := reflect.New(typ).Interface() - if err := json.Unmarshal(event.Payload, &message); err != nil { - return err - } - if err := handler(ctx, message); err == nil { - return nil - } else { - return err - } - } -} - -*/ - -func WithHandler[T any](routingKey string, handler EventHandler[T]) Setup { - exchangeName := topicExchangeName(defaultEventExchangeName) - return func(c *Connection) error { - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceEventQueueName(exchangeName, c.serviceName), - exchangeName: exchangeName, - kind: kindTopic, - headers: amqp.Table{}, - } - - return c.messageHandlerBindQueueToExchange(config) - } -} - // 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 From 19e667448a003ba310234f77372f8489e6e8424a Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 6 Feb 2024 10:41:42 +0100 Subject: [PATCH 12/50] chore: handler --- consumer.go | 9 +++++++-- examples/event-stream/example_test.go | 4 ++-- request_responae.go => request_response.go | 0 3 files changed, 9 insertions(+), 4 deletions(-) rename request_responae.go => request_response.go (100%) diff --git a/consumer.go b/consumer.go index b10da1b..a77c3ab 100644 --- a/consumer.go +++ b/consumer.go @@ -35,7 +35,7 @@ type ( // Handler is the type definition for a function that is used to handle events that has been mapped with // RoutingKey <-> Type mappings from WithTypeMapping. // If processing fails, an error should be returned and the message will be re-queued - Handler func(ctx context.Context, event any) error + Handler func(ctx context.Context, event ConsumableEvent[any]) error // EventHandler is the type definition for 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 @@ -54,7 +54,12 @@ func WithTypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { if err := json.Unmarshal(event.Payload, &message); err != nil { return err } - return handler(ctx, message) + msg := ConsumableEvent[any]{ + Metadata: event.Metadata, + DeliveryInfo: event.DeliveryInfo, + Payload: message, + } + return handler(ctx, msg) } } diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 82fa8ec..5ef48c1 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -115,8 +115,8 @@ func (s *ShippingService) Start(ctx context.Context) error { return s.connection.Start(ctx, goamqp.WithTypeMapping("Order.Created", OrderCreated{}), goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), - goamqp.EventStreamConsumer("#", goamqp.WithTypeMappingHandler(func(ctx context.Context, event any) error { - switch event.(type) { + goamqp.EventStreamConsumer("#", goamqp.WithTypeMappingHandler(func(ctx context.Context, event goamqp.ConsumableEvent[any]) error { + switch event.Payload.(type) { case *OrderCreated: s.output = append(s.output, "Order created") case *OrderUpdated: diff --git a/request_responae.go b/request_response.go similarity index 100% rename from request_responae.go rename to request_response.go From 0db2fd08d6385b0920f5cd563343f60ead5852a8 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 7 Feb 2024 12:55:10 +0100 Subject: [PATCH 13/50] chore: renaming event funcs and add spans and metrics --- connection.go | 64 ++++++++++------- go.mod | 8 +++ go.sum | 30 ++++++-- integration/tracing_test.go | 1 - metrics.go | 132 ++++++++++++++++++++++++++++++++++-- notifications.go | 33 ++++++++- setup.go | 4 +- tracing.go | 1 - 8 files changed, 230 insertions(+), 43 deletions(-) diff --git a/connection.go b/connection.go index 05ddd89..f542154 100644 --- a/connection.go +++ b/connection.go @@ -35,6 +35,8 @@ import ( "time" amqp "github.com/rabbitmq/amqp091-go" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" ) // Connection is a wrapper around the actual amqp.Connection and amqp.Channel @@ -299,10 +301,10 @@ func (c *Connection) publishMessage(ctx context.Context, msg any, routingKey, ex publishing, ) if err != nil { - EventPublishFailed(exchangeName, routingKey) + eventPublishFailed(exchangeName, routingKey) return err } - EventPublishSucceed(exchangeName, routingKey) + eventPublishSucceed(exchangeName, routingKey) return nil } @@ -318,42 +320,52 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue queueWithHandlers) { for delivery := range deliveries { - // TODO Copy readonly to context, write test! - handlerCtx := injectRoutingKeyToTypeContext(extractToContext(delivery.Headers), c.keyToType) - startTime := time.Now() deliveryInfo := getDeliveryInfo(queue.Name, delivery) - EventReceived(queue.Name, deliveryInfo.RoutingKey) + eventReceived(queue.Name, deliveryInfo.RoutingKey) // Establish which handler is invoked handler, ok := queue.Handlers.get(deliveryInfo.RoutingKey) if !ok { - EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) + eventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) _ = delivery.Reject(false) continue } + c.handleDelivery(handler, delivery, deliveryInfo) + } +} - uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} - if err := handler(handlerCtx, uevt); err != nil { - elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) - if errors.Is(err, ErrParseJSON) { - EventNotParsable(queue.Name, deliveryInfo.RoutingKey) - _ = delivery.Nack(false, false) - } else if errors.Is(err, ErrNoMessageTypeForRouteKey) { - EventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) - _ = delivery.Reject(false) - } else { - _ = delivery.Nack(false, true) - } - EventNack(queue.Name, deliveryInfo.RoutingKey, elapsed) - continue - } +func (c *Connection) handleDelivery(handler wrappedHandler, delivery amqp.Delivery, deliveryInfo DeliveryInfo) { + tracingCtx := extractToContext(delivery.Headers) + span := trace.SpanFromContext(tracingCtx) + if !span.SpanContext().IsValid() { + tracingCtx, span = otel.Tracer("amqp").Start(context.Background(), fmt.Sprintf("%s#%s", deliveryInfo.Queue, delivery.RoutingKey)) + } + defer span.End() + // TODO Copy readonly to context, write test! + handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.keyToType) + startTime := time.Now() + uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} + if err := handler(handlerCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) - _ = delivery.Ack(false) - EventAck(queue.Name, deliveryInfo.RoutingKey, elapsed) + notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, 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 { + _ = delivery.Nack(false, true) + } + eventNack(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) + return } + + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) + _ = delivery.Ack(false) + eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) } func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) error { diff --git a/go.mod b/go.mod index 2639e0b..3870327 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.22.12 require ( github.com/google/uuid v1.6.0 + 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 @@ -13,11 +14,18 @@ require ( ) 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/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.16.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 6a90e02..947883d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= @@ -6,27 +10,40 @@ 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/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= -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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= -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= @@ -40,9 +57,14 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/integration/tracing_test.go b/integration/tracing_test.go index 618fdea..ecca20a 100644 --- a/integration/tracing_test.go +++ b/integration/tracing_test.go @@ -56,7 +56,6 @@ func (suite *IntegrationTestSuite) Test_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 diff --git a/metrics.go b/metrics.go index 732fe12..34ccee3 100644 --- a/metrics.go +++ b/metrics.go @@ -22,23 +22,141 @@ package goamqp -func EventReceived(queue string, routingKey string) { +import ( + "errors" + + "github.com/prometheus/client_golang/prometheus" +) + +const ( + queue = "queue" + exchange = "exchange" + result = "result" + routingKey = "routing_key" +) + +var ( + eventReceivedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_received", + Help: "Count of AMQP events received", + }, []string{queue, routingKey}, + ) + + eventWithoutHandlerCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_without_handler", + Help: "Count of AMQP events without a handler", + }, []string{queue, routingKey}, + ) + + eventNotParsableCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_not_parsable", + Help: "Count of AMQP events that could not be parsed", + }, []string{queue, routingKey}, + ) + + eventNackCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_nack", + Help: "Count of AMQP events that were not acknowledged", + }, []string{queue, routingKey}, + ) + + eventAckCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_ack", + Help: "Count of AMQP events that were acknowledged", + }, []string{queue, routingKey}, + ) + + 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{queue, routingKey, result}, + ) + + eventPublishSucceedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_publish_succeed", + Help: "Count of AMQP events that could be published successfully", + }, []string{exchange, routingKey}, + ) + + eventPublishFailedCounter = prometheus.NewCounterVec( + prometheus.CounterOpts{ + Name: "amqp_events_publish_failed", + Help: "Count of AMQP events that could not be published", + }, []string{exchange, routingKey}, + ) +) + +func eventReceived(queue string, routingKey string) { + eventReceivedCounter.WithLabelValues(queue, routingKey).Inc() +} + +func eventWithoutHandler(queue string, routingKey string) { + eventWithoutHandlerCounter.WithLabelValues(queue, routingKey).Inc() } -func EventWithoutHandler(queue string, routingKey string) { +func eventNotParsable(queue string, routingKey string) { + eventNotParsableCounter.WithLabelValues(queue, routingKey).Inc() } -func EventNotParsable(queue string, routingKey string) { +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 EventNack(queue string, routingKey string, milliseconds int64) { +func eventPublishSucceed(exchange string, routingKey string) { + eventPublishSucceedCounter.WithLabelValues(exchange, routingKey).Inc() } -func EventAck(queue string, routingKey string, milliseconds int64) { +func eventPublishFailed(exchange string, routingKey string) { + eventPublishFailedCounter.WithLabelValues(exchange, routingKey).Inc() } -func EventPublishSucceed(exchange string, routingKey string) { +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 } -func EventPublishFailed(exchange string, routingKey string) { +// 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/notifications.go b/notifications.go index 0a092e2..3b179a6 100644 --- a/notifications.go +++ b/notifications.go @@ -22,14 +22,43 @@ package goamqp +import "fmt" + +type NotificationSource string + +const ( + NotificationSourceConsumer NotificationSource = "CONSUMER" +) + +type NotificationType string + +const ( + NotificationTypeInfo NotificationType = "INFO" + NotificationTypeError NotificationType = "ERROR" +) + type Notification struct { Message string - // Type NotificationType - // Source NotificationSource + Type NotificationType + Source NotificationSource } func notifyEventHandlerSucceed(ch chan<- Notification, routingKey string, took int64) { + if ch != nil { + ch <- Notification{ + Type: NotificationTypeInfo, + Message: fmt.Sprintf("event handler for %s succeeded, took %d milliseconds", routingKey, took), + Source: NotificationSourceConsumer, + } + } } func notifyEventHandlerFailed(ch chan<- Notification, routingKey string, took int64, err error) { + if ch != nil { + ch <- Notification{ + Type: NotificationTypeError, + Message: fmt.Sprintf("event handler for %s failed, took %d milliseconds, error: %s", routingKey, took, err), + Source: NotificationSourceConsumer, + } + } } diff --git a/setup.go b/setup.go index 15d6bda..c8f9686 100644 --- a/setup.go +++ b/setup.go @@ -42,10 +42,10 @@ type Setup func(conn *Connection) error func WithTypeMapping(routingKey string, msgType any) Setup { return func(conn *Connection) error { typ := reflect.TypeOf(msgType) - if t, exists := conn.keyToType[routingKey]; exists { + if t, exists := conn.keyToType[routingKey]; exists && t != typ { return fmt.Errorf("mapping for routing key '%s' already registered to type '%s'", routingKey, t) } - if key, exists := conn.typeToKey[typ]; exists { + if key, exists := conn.typeToKey[typ]; exists && key != routingKey { return fmt.Errorf("mapping for type '%s' already registered to routing key '%s'", typ, key) } conn.keyToType[routingKey] = typ diff --git a/tracing.go b/tracing.go index 8be3753..c59c9c7 100644 --- a/tracing.go +++ b/tracing.go @@ -34,7 +34,6 @@ import ( 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 } From b61dfe49e365d82e1a6425bb5da6f89477c593dd Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 7 Feb 2024 14:49:35 +0100 Subject: [PATCH 14/50] chore: remove publishable event --- publishableEvent.go | 83 --------------------------------------------- 1 file changed, 83 deletions(-) delete mode 100644 publishableEvent.go diff --git a/publishableEvent.go b/publishableEvent.go deleted file mode 100644 index 9bf7016..0000000 --- a/publishableEvent.go +++ /dev/null @@ -1,83 +0,0 @@ -// MIT License -// -// Copyright (c) 2024 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 ( - "time" - - "github.com/google/uuid" -) - -// PublishableEvent represents an event that can be published. -// The Payload field holds the event's payload data, which can be of -// any type that can be marshal to json. -type PublishableEvent struct { - Metadata - Payload any -} - -type eventOptions struct { - eventID string - correlationID string -} - -// WithEventID specifies the eventID to be published -// if it is not used a random uuid will be generated. -func WithEventID(eventID string) func(*eventOptions) { - return func(opt *eventOptions) { - opt.eventID = eventID - } -} - -// WithCorrelationID specifies the correlationID to be published -// if it is not used a random uuid will be generated. -func WithCorrelationID(correlationID string) func(*eventOptions) { - return func(opt *eventOptions) { - opt.correlationID = correlationID - } -} - -// NewPublishableEvent creates an instance of a PublishableEvent. -// In case the ID and correlation ID are not supplied via options random uuid will be generated. -func NewPublishableEvent(payload any, opts ...func(*eventOptions)) PublishableEvent { - evtOpts := eventOptions{} - for _, opt := range opts { - opt(&evtOpts) - } - - if evtOpts.correlationID == "" { - evtOpts.correlationID = uuid.NewString() - } - if evtOpts.eventID == "" { - evtOpts.eventID = uuid.NewString() - } - - return PublishableEvent{ - Metadata: Metadata{ - ID: evtOpts.eventID, - CorrelationID: evtOpts.correlationID, - Timestamp: time.Now(), - }, - Payload: payload, - } -} From 55c6b229730008f1870e91ffff36073d96e354d8 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 7 Feb 2024 14:49:47 +0100 Subject: [PATCH 15/50] chore: remove comment about multiple channels --- connection.go | 14 ++++---------- connection_test.go | 40 ++++++++++++++++------------------------ consumer_test.go | 1 - headers.go | 10 ---------- headers_test.go | 10 ++++------ request_response_test.go | 25 +++++++++++++++++++++++-- setup.go | 1 - setup_test.go | 15 ++++++++++----- 8 files changed, 57 insertions(+), 59 deletions(-) diff --git a/connection.go b/connection.go index f542154..d56720f 100644 --- a/connection.go +++ b/connection.go @@ -41,11 +41,10 @@ import ( // Connection is a wrapper around the actual amqp.Connection and amqp.Channel type Connection struct { - started bool - serviceName string - amqpUri amqp.URI - connection amqpConnection - // TODO One channel per queue/consumer + started bool + serviceName string + amqpUri amqp.URI + connection amqpConnection channel AmqpChannel queueHandlers *queueHandlers typeToKey TypeToRoutingKey @@ -118,7 +117,6 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { if c.started { return ErrAlreadyStarted } - // TODO Multiple channels if c.channel == nil { err := c.connectToAmqpURL() if err != nil { @@ -126,7 +124,6 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { } } - // TODO Qos from opt (per queue?) if err := c.channel.Qos(20, 0, true); err != nil { return err } @@ -253,7 +250,6 @@ func (c *Connection) connectToAmqpURL() error { if err != nil { return err } - // TODO Multiple channels ch, err := conn.Channel() if err != nil { return err @@ -341,7 +337,6 @@ func (c *Connection) handleDelivery(handler wrappedHandler, delivery amqp.Delive tracingCtx, span = otel.Tracer("amqp").Start(context.Background(), fmt.Sprintf("%s#%s", deliveryInfo.Queue, delivery.RoutingKey)) } defer span.End() - // TODO Copy readonly to context, write test! handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.keyToType) startTime := time.Now() @@ -414,7 +409,6 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { func (c *Connection) setup() error { for _, queue := range c.queueHandlers.queues() { - // TODO one channel per queue consumer, err := consume(c.channel, queue.Name) if err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", queue.Name, err) diff --git a/connection_test.go b/connection_test.go index f29465d..531c594 100644 --- a/connection_test.go +++ b/connection_test.go @@ -32,6 +32,7 @@ import ( "testing" amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -566,9 +567,8 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { require.EqualValues(t, m.published, i) } -/*func TestConnection_TypeMappingHandler(t *testing.T) { +func TestConnection_TypeMappingHandler(t *testing.T) { type fields struct { - typeToKey map[reflect.Type]string keyToType map[string]reflect.Type } type args struct { @@ -580,7 +580,6 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { name string fields fields args args - want any wantErr assert.ErrorAssertionFunc }{ { @@ -590,13 +589,14 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { msg: []byte(`{"a":true}`), key: "unknown", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) error { + return func(ctx context.Context, event ConsumableEvent[any]) error { return nil } }, }, - want: nil, - wantErr: assert.NoError, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorIs(t, err, ErrNoMessageTypeForRouteKey) + }, }, { name: "parse error", @@ -609,12 +609,11 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { msg: []byte(`{"a:}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) error { + return func(ctx context.Context, event ConsumableEvent[any]) error { return nil } }, }, - want: nil, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { return assert.EqualError(t, err, "unexpected end of JSON input") }, @@ -630,13 +629,12 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { msg: []byte(`{"a":true}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) (response any, err error) { - assert.IsType(t, &TestMessage{}, msg) - return nil, fmt.Errorf("handler-error") + return func(ctx context.Context, event ConsumableEvent[any]) error { + assert.IsType(t, &TestMessage{}, event.Payload) + return fmt.Errorf("handler-error") } }, }, - want: nil, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { return assert.EqualError(t, err, "handler-error") }, @@ -652,33 +650,27 @@ func (m *mockPublisher[R]) checkPublished(t *testing.T, i R) { msg: []byte(`{"a":true}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, msg any) (response any, err error) { - assert.IsType(t, &TestMessage{}, msg) - return "OK", nil + return func(ctx context.Context, event ConsumableEvent[any]) error { + assert.IsType(t, &TestMessage{}, event.Payload) + return 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, - } + ctx := injectRoutingKeyToTypeContext(context.TODO(), tt.fields.keyToType) - handler := c.TypeMappingHandler(tt.args.handler(t)) - res, err := handler(context.TODO(), ConsumableEvent[json.RawMessage]{ + handler := WithTypeMappingHandler(tt.args.handler(t)) + err := handler(ctx, ConsumableEvent[json.RawMessage]{ Payload: tt.args.msg, DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, }) if !tt.wantErr(t, err) { return } - assert.Equalf(t, tt.want, res, "TypeMappingHandler()") }) } } -*/ diff --git a/consumer_test.go b/consumer_test.go index 5335f4e..f1d8aa5 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -158,7 +158,6 @@ func Test_Consumer_Setups(t *testing.T) { } else { require.NoError(t, err) } - // TODO require.Equal(t, tt.expectedHandler, conn.queueHandlers) }) } } diff --git a/headers.go b/headers.go index 8eb16a8..d221694 100644 --- a/headers.go +++ b/headers.go @@ -25,8 +25,6 @@ package goamqp import ( "errors" "fmt" - - amqp "github.com/rabbitmq/amqp091-go" ) // Header represent meta-data for the message @@ -69,12 +67,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 2ff7808..7ac5d77 100644 --- a/headers_test.go +++ b/headers_test.go @@ -25,7 +25,6 @@ package goamqp import ( "testing" - amqp "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" ) @@ -33,15 +32,14 @@ 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") @@ -49,6 +47,6 @@ func Test_Headers(t *testing.T) { h = map[string]any{"": "p"} require.EqualError(t, h.validate(), "empty key not allowed") - 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/request_response_test.go b/request_response_test.go index 58874ca..9311aa0 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -24,6 +24,7 @@ package goamqp import ( "context" + "encoding/json" "reflect" "runtime" "testing" @@ -35,8 +36,14 @@ import ( func Test_RequestResponseHandler(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) - err := RequestResponseHandler("key", func(ctx context.Context, msg ConsumableEvent[Message]) (response any, err error) { - return nil, nil + 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) @@ -54,4 +61,18 @@ func Test_RequestResponseHandler(t *testing.T) { handler, _ := conn.queueHandlers.handlers("svc.direct.exchange.request.queue").get("key") require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + + 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/setup.go b/setup.go index c8f9686..e465709 100644 --- a/setup.go +++ b/setup.go @@ -79,7 +79,6 @@ func WithNotificationChannel(notificationCh chan<- Notification) Setup { } } -// TODO REMOVE and use WithNotificationChannel instead? // CloseListener receives a callback when the AMQP Channel gets closed func CloseListener(e chan error) Setup { return func(c *Connection) error { diff --git a/setup_test.go b/setup_test.go index d05e08f..541aa19 100644 --- a/setup_test.go +++ b/setup_test.go @@ -27,7 +27,6 @@ import ( "testing" amqp "github.com/rabbitmq/amqp091-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -84,16 +83,22 @@ func Test_WithTypeMapping_KeyAlreadyExist(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) err := WithTypeMapping("key", TestMessage{})(conn) - assert.NoError(t, err) + require.NoError(t, err) err = WithTypeMapping("key", TestMessage2{})(conn) - assert.EqualError(t, err, "mapping for routing key 'key' already registered to type 'goamqp.TestMessage'") + require.EqualError(t, err, "mapping for routing key 'key' already registered to type 'goamqp.TestMessage'") + + err = WithTypeMapping("key", TestMessage{})(conn) + require.NoError(t, err) } func Test_WithTypeMapping_TypeAlreadyExist(t *testing.T) { channel := NewMockAmqpChannel() conn := mockConnection(channel) err := WithTypeMapping("key", TestMessage{})(conn) - assert.NoError(t, err) + require.NoError(t, err) err = WithTypeMapping("other", TestMessage{})(conn) - assert.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to routing key 'key'") + require.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to routing key 'key'") + + err = WithTypeMapping("key", TestMessage{})(conn) + require.NoError(t, err) } From 2ac1bd49ce39152b13baf892d44a30bb8fbf1f48 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 08:53:13 +0100 Subject: [PATCH 16/50] chore: remove redundant code --- connection.go | 62 +++++----------------------------------- connection_test.go | 17 +++-------- consumer.go | 21 ++++++-------- consumer_test.go | 34 +++++++++++----------- publisher_test.go | 8 +++--- request_response_test.go | 6 ++-- setup.go | 6 ++++ 7 files changed, 50 insertions(+), 104 deletions(-) diff --git a/connection.go b/connection.go index d56720f..d1508de 100644 --- a/connection.go +++ b/connection.go @@ -63,6 +63,7 @@ type QueueBindingConfig struct { exchangeName string kind kind headers amqp.Table + transient bool } // QueueBindingConfigSetup is a setup function that takes a QueueBindingConfig and provide custom changes to the @@ -192,36 +193,11 @@ func responseWrapper[T, R any](handler RequestResponseEventHandler[T, R], routin } func consume(channel AmqpChannel, queue string) (<-chan amqp.Delivery, error) { - return channel.Consume( - queue, - "", - false, - false, - false, - false, - amqp.Table{}, - ) + return channel.Consume(queue, "", false, false, false, false, nil) } -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, - ) +func queueDeclare(channel AmqpChannel, name string, transient bool) error { + _, err := channel.QueueDeclare(name, !transient, transient, false, false, queueDeclareExpiration) return err } @@ -261,17 +237,7 @@ func (c *Connection) connectToAmqpURL() error { } 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, - ) + return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) } func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHandler) error { @@ -351,9 +317,9 @@ func (c *Connection) handleDelivery(handler wrappedHandler, delivery amqp.Delive eventWithoutHandler(deliveryInfo.Queue, deliveryInfo.RoutingKey) _ = delivery.Reject(false) } else { + eventNack(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) _ = delivery.Nack(false, true) } - eventNack(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) return } @@ -371,7 +337,7 @@ func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) if err := c.exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { return err } - if err := queueDeclare(c.channel, cfg.queueName); err != nil { + if err := queueDeclare(c.channel, cfg.queueName, cfg.transient); err != nil { return err } return c.channel.QueueBind(cfg.queueName, cfg.routingKey, cfg.exchangeName, false, cfg.headers) @@ -418,20 +384,6 @@ func (c *Connection) setup() error { 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 - -// 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() diff --git a/connection_test.go b/connection_test.go index 531c594..8681fa2 100644 --- a/connection_test.go +++ b/connection_test.go @@ -40,15 +40,6 @@ func Test_AmqpVersion(t *testing.T) { require.Equal(t, "_unknown_", version()) } -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) -} - func Test_Start_MultipleCallsFails(t *testing.T) { mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} mockChannel := &MockAmqpChannel{ @@ -249,7 +240,7 @@ func Test_AmqpConfig(t *testing.T) { func Test_QueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, "test") + err := queueDeclare(channel, "test", false) 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]) @@ -257,7 +248,7 @@ func Test_QueueDeclare(t *testing.T) { func Test_TransientQueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := transientQueueDeclare(channel, "test") + err := queueDeclare(channel, "test", true) require.NoError(t, err) require.Equal(t, 1, len(channel.QueueDeclarations)) @@ -272,7 +263,7 @@ func Test_ExchangeDeclare(t *testing.T) { err := conn.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) { @@ -282,7 +273,7 @@ func Test_Consume(t *testing.T) { 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{}, + consumer: "", autoAck: false, exclusive: false, noLocal: false, noWait: false, args: nil, }, channel.Consumers[0]) } diff --git a/consumer.go b/consumer.go index a77c3ab..96e3303 100644 --- a/consumer.go +++ b/consumer.go @@ -123,7 +123,6 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T]) S queueName: serviceRequestQueueName(c.serviceName), exchangeName: serviceRequestExchangeName(c.serviceName), kind: kindDirect, - headers: amqp.Table{}, } return c.messageHandlerBindQueueToExchange(config) @@ -140,7 +139,6 @@ func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], queueName: serviceEventQueueName(exchangeName, c.serviceName), exchangeName: exchangeName, kind: kindTopic, - headers: amqp.Table{}, } for _, f := range opts { if err := f(config); err != nil { @@ -164,18 +162,17 @@ func TransientEventStreamConsumer[T any](routingKey string, handler EventHandler // For a durable queue, use the StreamConsumer function instead. func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHandler[T]) Setup { exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) - if err := c.addHandler(queueName, routingKey, newWrappedHandler(handler)); 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 + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: queueName, + exchangeName: exchangeName, + kind: kindTopic, + transient: true, } - return c.channel.QueueBind(queueName, routingKey, exchangeName, false, amqp.Table{}) + return c.messageHandlerBindQueueToExchange(config) } } diff --git a/consumer_test.go b/consumer_test.go index f1d8aa5..c6e8ddb 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -51,20 +51,20 @@ func Test_Consumer_Setups(t *testing.T) { 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: amqp.Table{}}}, + 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, args: amqp.Table{"x-expires": 432000000}}}, - 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: amqp.Table{}}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc", noWait: false, exchange: "events.topic.exchange", key: "key", args: nil}}, + 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: amqp.Table{}}}, + 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, args: amqp.Table{"x-expires": 432000000}}}, - 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: amqp.Table{}}}, + expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-suffix", noWait: false, exchange: "events.topic.exchange", key: "key", args: nil}}, + 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", @@ -79,22 +79,22 @@ func Test_Consumer_Setups(t *testing.T) { return nil })}, expectedExchanges: []ExchangeDeclaration{ - {name: "svc.headers.exchange.response", noWait: false, internal: false, autoDelete: false, durable: true, kind: "headers", args: amqp.Table{}}, - {name: "svc.direct.exchange.request", noWait: false, internal: false, autoDelete: false, durable: true, kind: "direct", args: amqp.Table{}}, + {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, args: amqp.Table{"x-expires": 432000000}}}, - 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: amqp.Table{}}}, + expectedBindings: []BindingDeclaration{{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: nil}}, + 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: amqp.Table{}}}, + 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, args: amqp.Table{"x-expires": 432000000}}}, 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: amqp.Table{}}}, + expectedConsumer: []Consumer{{queue: "targetService.headers.exchange.response.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, expectedHandler: &queueHandlers{"targetService.headers.exchange.response.queue.svc": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { return nil }}}, @@ -104,10 +104,10 @@ func Test_Consumer_Setups(t *testing.T) { 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: amqp.Table{}}}, - expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: amqp.Table{}}}, + 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: nil}}, expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, - expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: amqp.Table{}}}, + expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, expectedHandler: &queueHandlers{"events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { return nil }}}, @@ -121,8 +121,8 @@ func Test_Consumer_Setups(t *testing.T) { return errors.New("failed") }), }, - expectedExchanges: []ExchangeDeclaration{{name: "events.topic.exchange", kind: "topic", durable: true, autoDelete: false, internal: false, noWait: false, args: amqp.Table{}}}, - 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{}}}, + 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: nil}}, expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, expectedError: "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix", }, diff --git a/publisher_test.go b/publisher_test.go index 2a0e283..c0b2ea4 100644 --- a/publisher_test.go +++ b/publisher_test.go @@ -74,7 +74,7 @@ func Test_Publisher_Setups(t *testing.T) { }, messages: []any{TestMessage{"test", true}}, headers: []Header{{"x-header", "header"}}, - expectedExchanges: []ExchangeDeclaration{{name: topicExchangeName(defaultEventExchangeName), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindTopic, args: amqp.Table{}}}, + expectedExchanges: []ExchangeDeclaration{{name: topicExchangeName(defaultEventExchangeName), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindTopic, args: nil}}, expectedPublished: []*Publish{{ exchange: topicExchangeName(defaultEventExchangeName), key: "key", @@ -93,7 +93,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p), WithTypeMapping("key", TestMessage{})} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, messages: []any{TestMessage{"test", true}}, expectedPublished: []*Publish{{ exchange: serviceRequestExchangeName("svc"), @@ -113,7 +113,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p), WithTypeMapping("key1", TestMessage{}), WithTypeMapping("key2", TestMessage2{})} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, messages: []any{ TestMessage{"test", true}, TestMessage2{"test", false}, @@ -161,7 +161,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p)} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: amqp.Table{}}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, messages: []any{ TestMessage{"test", true}, }, diff --git a/request_response_test.go b/request_response_test.go index 9311aa0..cd4bf2b 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -48,14 +48,14 @@ func Test_RequestResponseHandler(t *testing.T) { 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, 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, 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, BindingDeclaration{queue: "svc.direct.exchange.request.queue", noWait: false, exchange: "svc.direct.exchange.request", key: "key", args: nil}, channel.BindingDeclarations[0]) require.Equal(t, 1, len(conn.queueHandlers.queues())) diff --git a/setup.go b/setup.go index e465709..7c5d036 100644 --- a/setup.go +++ b/setup.go @@ -26,6 +26,7 @@ import ( "errors" "fmt" "reflect" + "runtime" amqp "github.com/rabbitmq/amqp091-go" ) @@ -102,3 +103,8 @@ func PublishNotify(confirm chan amqp.Confirmation) Setup { 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() +} From 29b462f1ee2c9e32e407f37edd6fc1ed73dd9229 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 08:57:25 +0100 Subject: [PATCH 17/50] chore: remove method from connection --- connection.go | 10 +++++----- connection_test.go | 4 +--- consumer.go | 2 +- publisher.go | 4 ++-- 4 files changed, 9 insertions(+), 11 deletions(-) diff --git a/connection.go b/connection.go index d1508de..c276c29 100644 --- a/connection.go +++ b/connection.go @@ -196,6 +196,10 @@ func consume(channel AmqpChannel, queue string) (<-chan amqp.Delivery, error) { return channel.Consume(queue, "", false, false, false, false, nil) } +func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { + return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) +} + func queueDeclare(channel AmqpChannel, name string, transient bool) error { _, err := channel.QueueDeclare(name, !transient, transient, false, false, queueDeclareExpiration) return err @@ -236,10 +240,6 @@ func (c *Connection) connectToAmqpURL() error { return nil } -func (c *Connection) exchangeDeclare(channel AmqpChannel, name string, kind kind) error { - return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) -} - func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHandler) error { return c.queueHandlers.add(queueName, routingKey, handler) } @@ -334,7 +334,7 @@ func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) return err } - if err := c.exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { + if err := exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { return err } if err := queueDeclare(c.channel, cfg.queueName, cfg.transient); err != nil { diff --git a/connection_test.go b/connection_test.go index 8681fa2..e81f4d2 100644 --- a/connection_test.go +++ b/connection_test.go @@ -258,9 +258,7 @@ func Test_TransientQueueDeclare(t *testing.T) { 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: nil}, channel.ExchangeDeclarations[0]) diff --git a/consumer.go b/consumer.go index 96e3303..196cbdf 100644 --- a/consumer.go +++ b/consumer.go @@ -113,7 +113,7 @@ func ServiceResponseConsumer[T any](targetService, routingKey string, handler Ev func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T]) Setup { return func(c *Connection) error { resExchangeName := serviceResponseExchangeName(c.serviceName) - if err := c.exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { + if err := exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) } diff --git a/publisher.go b/publisher.go index 36c0b4f..7f27411 100644 --- a/publisher.go +++ b/publisher.go @@ -82,7 +82,7 @@ func EventStreamPublisher(publisher *Publisher) Setup { 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 { + if err := exchangeDeclare(c.channel, name, kindTopic); err != nil { return fmt.Errorf("failed to declare exchange %s, %w", name, err) } publisher.connection = c @@ -119,7 +119,7 @@ func ServicePublisher(targetService string, publisher *Publisher) Setup { return err } publisher.exchange = reqExchangeName - if err := c.exchangeDeclare(c.channel, reqExchangeName, kindDirect); err != nil { + if err := exchangeDeclare(c.channel, reqExchangeName, kindDirect); err != nil { return err } return nil From c1f64573b973844eca34bee77c0576ef3ac608a3 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 09:51:42 +0100 Subject: [PATCH 18/50] chore: naming --- connection_test.go | 2 +- consumer.go | 51 +++++++++++++++------------ examples/event-stream/example_test.go | 6 +--- 3 files changed, 30 insertions(+), 29 deletions(-) diff --git a/connection_test.go b/connection_test.go index e81f4d2..c4b6e47 100644 --- a/connection_test.go +++ b/connection_test.go @@ -652,7 +652,7 @@ func TestConnection_TypeMappingHandler(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := injectRoutingKeyToTypeContext(context.TODO(), tt.fields.keyToType) - handler := WithTypeMappingHandler(tt.args.handler(t)) + handler := TypeMappingHandler(tt.args.handler(t)) err := handler(ctx, ConsumableEvent[json.RawMessage]{ Payload: tt.args.msg, DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, diff --git a/consumer.go b/consumer.go index 196cbdf..0c68c30 100644 --- a/consumer.go +++ b/consumer.go @@ -45,7 +45,11 @@ type ( RequestResponseEventHandler[T any, R any] func(ctx context.Context, event ConsumableEvent[T]) (R, error) ) -func WithTypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { +// TypeMappingHandler wraps a Handler func into an EventHandler in order to use it with the different +// Consumer Setup func. +// It will use the mappings from WithTypeMapping to determine routing key -> actual event type and pass it to the +// handler func. +func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { message, exists := routingKeyToTypeFromContext(ctx, event) if !exists { @@ -63,28 +67,6 @@ func WithTypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { } } -type routingKeyToTypeCtx string - -const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" - -func injectRoutingKeyToTypeContext(ctx context.Context, keyToType RoutingKeyToType) context.Context { - return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) -} - -func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { - routingKey := event.DeliveryInfo.RoutingKey - keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(RoutingKeyToType) - if !ok { - return nil, false - } - - typ, exists := keyToType[routingKey] - if !exists { - return nil, false - } - return reflect.New(typ).Interface(), true -} - // 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 ...QueueBindingConfigSetup) Setup { @@ -176,3 +158,26 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa return c.messageHandlerBindQueueToExchange(config) } } + +// Handles WithTypeMapping mappings in context.Context +type routingKeyToTypeCtx string + +const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" + +func injectRoutingKeyToTypeContext(ctx context.Context, keyToType RoutingKeyToType) context.Context { + return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) +} + +func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { + routingKey := event.DeliveryInfo.RoutingKey + keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(RoutingKeyToType) + if !ok { + return nil, false + } + + typ, exists := keyToType[routingKey] + if !exists { + return nil, false + } + return reflect.New(typ).Interface(), true +} diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 5ef48c1..8a3b3e1 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -115,7 +115,7 @@ func (s *ShippingService) Start(ctx context.Context) error { return s.connection.Start(ctx, goamqp.WithTypeMapping("Order.Created", OrderCreated{}), goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), - goamqp.EventStreamConsumer("#", goamqp.WithTypeMappingHandler(func(ctx context.Context, event goamqp.ConsumableEvent[any]) error { + 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") @@ -141,7 +141,3 @@ type OrderUpdated struct { Id string Data string } - -type ShippingUpdated struct { - Id string -} From 254abdd3f18026730d585572c100530e621975c5 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 10:11:45 +0100 Subject: [PATCH 19/50] chore: moved funcs --- connection.go | 83 +++++------------------------------------ queue_binding_config.go | 41 ++++++++++++++++++++ request_response.go | 36 +++++++++++++++++- 3 files changed, 86 insertions(+), 74 deletions(-) create mode 100644 queue_binding_config.go diff --git a/connection.go b/connection.go index c276c29..3af2cae 100644 --- a/connection.go +++ b/connection.go @@ -30,7 +30,6 @@ import ( "io" "os" "reflect" - "runtime" "runtime/debug" "time" @@ -53,34 +52,7 @@ type Connection struct { } // ServiceResponsePublisher represents the function that is called to publish a response -type ServiceResponsePublisher[T any] func(ctx context.Context, targetService, routingKey string, msg T) error - -// QueueBindingConfig is a wrapper around the actual amqp queue configuration -type QueueBindingConfig struct { - routingKey string - handler wrappedHandler - queueName string - exchangeName string - kind kind - headers amqp.Table - transient bool -} - -// 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 @@ -174,37 +146,10 @@ func version() string { return "_unknown_" } -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 - } -} - func consume(channel AmqpChannel, queue string) (<-chan amqp.Delivery, error) { return channel.Consume(queue, "", false, false, false, false, nil) } -func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { - return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) -} - -func queueDeclare(channel AmqpChannel, name string, transient bool) error { - _, err := channel.QueueDeclare(name, !transient, transient, false, false, queueDeclareExpiration) - return err -} - func amqpConfig(serviceName string) amqp.Config { config := amqp.Config{ Properties: amqp.NewConnectionProperties(), @@ -343,6 +288,15 @@ func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) return c.channel.QueueBind(cfg.queueName, cfg.routingKey, cfg.exchangeName, false, cfg.headers) } +func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { + return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) +} + +func queueDeclare(channel AmqpChannel, name string, transient bool) error { + _, err := channel.QueueDeclare(name, !transient, transient, false, false, queueDeclareExpiration) + return err +} + type kind string const ( @@ -383,20 +337,3 @@ func (c *Connection) setup() error { } return nil } - -// 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(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/queue_binding_config.go b/queue_binding_config.go new file mode 100644 index 0000000..8215c04 --- /dev/null +++ b/queue_binding_config.go @@ -0,0 +1,41 @@ +package goamqp + +import ( + "fmt" + "reflect" + "runtime" + + amqp "github.com/rabbitmq/amqp091-go" +) + +// QueueBindingConfigSetup is a setup function that takes a QueueBindingConfig and provide custom changes to the +// configuration +type QueueBindingConfigSetup func(config *QueueBindingConfig) error + +// QueueBindingConfig is a wrapper around the actual amqp queue configuration +type QueueBindingConfig struct { + routingKey string + handler wrappedHandler + queueName string + exchangeName string + kind kind + headers amqp.Table + transient bool +} + +// 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 + } +} + +// getQueueBindingConfigSetupFuncName returns the name of the QueueBindingConfigSetup function +func getQueueBindingConfigSetupFuncName(f QueueBindingConfigSetup) string { + return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() +} diff --git a/request_response.go b/request_response.go index 6f34ba8..5c310b4 100644 --- a/request_response.go +++ b/request_response.go @@ -22,7 +22,11 @@ package goamqp -import "context" +import ( + "context" + "errors" + "fmt" +) // RequestResponseHandler is a convenience func to set up ServiceRequestConsumer and combines it with // PublishServiceResponse @@ -34,3 +38,33 @@ func RequestResponseHandler[T any, R any](routingKey string, handler RequestResp 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") +} From 155e19129543ae2468ca2448813f316e2fc092cb Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 10:12:04 +0100 Subject: [PATCH 20/50] chore: moved publish from connection --- connection.go | 29 +---------------------------- connection_test.go | 10 ++-------- publisher.go | 43 +++++++++++++++++++++++++++++++++++++------ 3 files changed, 40 insertions(+), 42 deletions(-) diff --git a/connection.go b/connection.go index 3af2cae..9601351 100644 --- a/connection.go +++ b/connection.go @@ -24,7 +24,6 @@ package goamqp import ( "context" - "encoding/json" "errors" "fmt" "io" @@ -78,7 +77,7 @@ func NewFromURL(serviceName string, amqpURL string) (*Connection, error) { // 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 { @@ -189,32 +188,6 @@ func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHan return c.queueHandlers.add(queueName, routingKey, handler) } -func (c *Connection) publishMessage(ctx context.Context, 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 = c.channel.PublishWithContext(ctx, exchangeName, - routingKey, - false, - false, - publishing, - ) - if err != nil { - eventPublishFailed(exchangeName, routingKey) - return err - } - eventPublishSucceed(exchangeName, routingKey) - return nil -} - func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { deliveryInfo := DeliveryInfo{ Queue: queueName, diff --git a/connection_test.go b/connection_test.go index c4b6e47..6fb9b48 100644 --- a/connection_test.go +++ b/connection_test.go @@ -279,10 +279,7 @@ func Test_Publish(t *testing.T) { channel := NewMockAmqpChannel() headers := amqp.Table{} headers["key"] = "value" - c := Connection{ - channel: channel, - } - 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 @@ -314,10 +311,7 @@ func Test_Publish_Marshal_Error(t *testing.T) { channel := NewMockAmqpChannel() headers := amqp.Table{} headers["key"] = "value" - c := Connection{ - channel: channel, - } - 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") } diff --git a/publisher.go b/publisher.go index 7f27411..1554ec8 100644 --- a/publisher.go +++ b/publisher.go @@ -24,6 +24,7 @@ package goamqp import ( "context" + "encoding/json" "fmt" "reflect" @@ -32,7 +33,8 @@ import ( // Publisher is used to send messages type Publisher struct { - connection *Connection + typeToKey TypeToRoutingKey + channel AmqpChannel exchange string defaultHeaders []Header } @@ -67,8 +69,8 @@ func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) err 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) + if key, ok := p.typeToKey[key]; ok { + return publishMessage(ctx, p.channel, msg, key, p.exchange, table) } return fmt.Errorf("%w %s", ErrNoRouteForMessageType, t) } @@ -85,7 +87,8 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { if err := exchangeDeclare(c.channel, name, kindTopic); err != nil { return fmt.Errorf("failed to declare exchange %s, %w", name, err) } - publisher.connection = c + publisher.channel = c.channel + publisher.typeToKey = c.typeToKey if err := publisher.setDefaultHeaders(c.serviceName); err != nil { return err } @@ -99,7 +102,8 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { // 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 + publisher.channel = c.channel + publisher.typeToKey = c.typeToKey if err := publisher.setDefaultHeaders(c.serviceName, Header{Key: "CC", Value: []any{destinationQueueName}}, ); err != nil { @@ -114,7 +118,8 @@ func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { func ServicePublisher(targetService string, publisher *Publisher) Setup { return func(c *Connection) error { reqExchangeName := serviceRequestExchangeName(targetService) - publisher.connection = c + publisher.channel = c.channel + publisher.typeToKey = c.typeToKey if err := publisher.setDefaultHeaders(c.serviceName); err != nil { return err } @@ -135,3 +140,29 @@ func (p *Publisher) setDefaultHeaders(serviceName string, headers ...Header) err p.defaultHeaders = append(headers, Header{Key: headerService, Value: serviceName}) 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 +} From 7d85c0294c5e38b87cba375b1cb750c5f35fb437 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 13:11:13 +0100 Subject: [PATCH 21/50] chore: move around tests --- .pre-commit-config.yaml | 2 +- channel.go | 2 +- connection.go | 86 +----- connection_test.go | 294 +------------------ consumer.go | 225 +++++--------- handler.go | 104 +------ headers.go | 4 +- headers_test.go | 2 +- metrics.go | 24 +- must_test.go | 41 +++ queue_binding_config.go | 2 +- queue_binding_config_test.go | 39 +++ request_response_test.go | 5 +- routingkey_handlers.go | 64 ++++ routingkey_handlers_test.go | 91 ++++++ setup.go | 6 +- setup_consumer.go | 183 ++++++++++++ consumer_test.go => setup_consumer_test.go | 144 ++++++++- publisher.go => setup_publisher.go | 43 +-- publisher_test.go => setup_publisher_test.go | 5 + setup_test.go | 30 ++ 21 files changed, 724 insertions(+), 672 deletions(-) create mode 100644 must_test.go create mode 100644 queue_binding_config_test.go create mode 100644 routingkey_handlers.go create mode 100644 routingkey_handlers_test.go create mode 100644 setup_consumer.go rename consumer_test.go => setup_consumer_test.go (71%) rename publisher.go => setup_publisher.go (79%) rename publisher_test.go => setup_publisher_test.go (97%) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0819896..6cddab7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -35,4 +35,4 @@ repos: - --comment-style - // # defaults to: # - --use-current-year - - --no-extra-eol # see below + - --no-extra-eol diff --git a/channel.go b/channel.go index 56fd423..5392072 100644 --- a/channel.go +++ b/channel.go @@ -39,7 +39,7 @@ type AmqpChannel interface { 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 + // 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 diff --git a/connection.go b/connection.go index 9601351..af961af 100644 --- a/connection.go +++ b/connection.go @@ -24,7 +24,6 @@ package goamqp import ( "context" - "errors" "fmt" "io" "os" @@ -33,8 +32,6 @@ import ( "time" amqp "github.com/rabbitmq/amqp091-go" - "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" ) // Connection is a wrapper around the actual amqp.Connection and amqp.Channel @@ -44,9 +41,9 @@ type Connection struct { amqpUri amqp.URI connection amqpConnection channel AmqpChannel - queueHandlers *queueHandlers - typeToKey TypeToRoutingKey - keyToType RoutingKeyToType + queueConsumers *queueConsumers + typeToKey typeToRoutingKey + keyToType routingKeyToType notificationCh chan<- Notification } @@ -145,10 +142,6 @@ func version() string { return "_unknown_" } -func consume(channel AmqpChannel, queue string) (<-chan amqp.Delivery, error) { - return channel.Consume(queue, "", false, false, false, false, nil) -} - func amqpConfig(serviceName string) amqp.Config { config := amqp.Config{ Properties: amqp.NewConnectionProperties(), @@ -185,7 +178,7 @@ func (c *Connection) connectToAmqpURL() error { } func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHandler) error { - return c.queueHandlers.add(queueName, routingKey, handler) + return c.queueConsumers.add(queueName, routingKey, handler) } func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { @@ -198,55 +191,6 @@ func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { return deliveryInfo } -func (c *Connection) divertToMessageHandlers(deliveries <-chan amqp.Delivery, queue queueWithHandlers) { - for delivery := range deliveries { - deliveryInfo := getDeliveryInfo(queue.Name, delivery) - eventReceived(queue.Name, deliveryInfo.RoutingKey) - - // Establish which handler is invoked - handler, ok := queue.Handlers.get(deliveryInfo.RoutingKey) - if !ok { - eventWithoutHandler(queue.Name, deliveryInfo.RoutingKey) - _ = delivery.Reject(false) - continue - } - c.handleDelivery(handler, delivery, deliveryInfo) - } -} - -func (c *Connection) handleDelivery(handler wrappedHandler, delivery amqp.Delivery, deliveryInfo DeliveryInfo) { - tracingCtx := extractToContext(delivery.Headers) - span := trace.SpanFromContext(tracingCtx) - if !span.SpanContext().IsValid() { - tracingCtx, span = otel.Tracer("amqp").Start(context.Background(), fmt.Sprintf("%s#%s", deliveryInfo.Queue, delivery.RoutingKey)) - } - defer span.End() - handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.keyToType) - startTime := time.Now() - - uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} - if err := handler(handlerCtx, uevt); err != nil { - elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, 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.RoutingKey, elapsed) - _ = delivery.Ack(false) - eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) -} - func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) error { if err := c.addHandler(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err @@ -292,21 +236,23 @@ var ( func newConnection(serviceName string, uri amqp.URI) *Connection { return &Connection{ - serviceName: serviceName, - amqpUri: uri, - queueHandlers: &queueHandlers{}, - keyToType: make(map[string]reflect.Type), - typeToKey: make(map[reflect.Type]string), + serviceName: serviceName, + amqpUri: uri, + queueConsumers: &queueConsumers{}, + keyToType: make(map[string]reflect.Type), + typeToKey: make(map[reflect.Type]string), } } 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 { + if err := consumer.consume(c.channel, c.keyToType, c.notificationCh); err != nil { + return fmt.Errorf("failed to create consumer for queue %s. %v", consumer.queue, err) } - go c.divertToMessageHandlers(consumer, queue) } return nil } + +type routingKeyToType map[string]reflect.Type + +type typeToRoutingKey map[reflect.Type]string diff --git a/connection_test.go b/connection_test.go index 6fb9b48..4985040 100644 --- a/connection_test.go +++ b/connection_test.go @@ -28,11 +28,9 @@ import ( "errors" "fmt" "math" - "reflect" "testing" amqp "github.com/rabbitmq/amqp091-go" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -49,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) @@ -85,10 +84,10 @@ func Test_Start_SetupFails(t *testing.T) { }, } conn := &Connection{ - serviceName: "test", - connection: mockAmqpConnection, - channel: mockChannel, - queueHandlers: &queueHandlers{}, + serviceName: "test", + connection: mockAmqpConnection, + channel: mockChannel, + queueConsumers: &queueConsumers{}, } err := conn.Start(context.Background(), EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) error { @@ -98,34 +97,6 @@ func Test_Start_SetupFails(t *testing.T) { 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) { - 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, - } - err := conn.Start(context.Background(), - WithPrefetchLimit(1), - ) - require.NoError(t, err) -} - func Test_Start_ConnectionFail(t *testing.T) { orgDial := dialAmqp defer func() { dialAmqp = orgDial }() @@ -139,18 +110,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) @@ -264,17 +223,6 @@ func Test_ExchangeDeclare(t *testing.T) { 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) { - 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: nil, - }, channel.Consumers[0]) -} - func Test_Publish(t *testing.T) { channel := NewMockAmqpChannel() headers := amqp.Table{} @@ -386,43 +334,6 @@ 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 := queueHandlers{} - handler := newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { - if msg.Payload.Ok { - return nil - } - return errors.New("failed") - }) - require.NoError(t, handlers.add("q", "key1", handler)) - require.NoError(t, handlers.add("q", "key2", handler)) - - 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, - } - c.divertToMessageHandlers(queueDeliveries, handlers.queues()[0]) - - 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{ @@ -442,93 +353,12 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { 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_Nack_IfParseFails(t *testing.T) { - require.Equal(t, Nack{tag: 0x0, requeue: false}, <-testHandleMessage("", true).Nacks) -} - -func testHandleMessage(json string, handle bool) MockAcknowledger { - type Message struct{} - acker := NewMockAcknowledger() - delivery := amqp.Delivery{ - Body: []byte(json), - Acknowledger: &acker, - RoutingKey: "key", - } - c := &Connection{} - deliveries := make(chan amqp.Delivery) - queue := queueWithHandlers{ - Name: "", - Handlers: &handlers{ - "key": newWrappedHandler(func(ctx context.Context, msg ConsumableEvent[Message]) error { - if handle { - return nil - } - return errors.New("failed") - }), - }, - } - go func() { - deliveries <- delivery - close(deliveries) - }() - c.divertToMessageHandlers(deliveries, queue) - 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.Publish(context.Background(), 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 } @@ -549,111 +379,3 @@ func (m *mockPublisher[R]) publish(ctx context.Context, targetService, routingKe 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 { - keyToType map[string]reflect.Type - } - type args struct { - handler func(t *testing.T) Handler - msg json.RawMessage - key string - } - tests := []struct { - name string - fields fields - args args - wantErr assert.ErrorAssertionFunc - }{ - { - name: "no mapped type, ignored", - fields: fields{}, - args: args{ - msg: []byte(`{"a":true}`), - key: "unknown", - handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - return nil - } - }, - }, - wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.ErrorIs(t, err, ErrNoMessageTypeForRouteKey) - }, - }, - { - 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) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - return 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) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - assert.IsType(t, &TestMessage{}, event.Payload) - 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{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, - }, - args: args{ - msg: []byte(`{"a":true}`), - key: "known", - handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - assert.IsType(t, &TestMessage{}, event.Payload) - return nil - } - }, - }, - wantErr: assert.NoError, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - ctx := injectRoutingKeyToTypeContext(context.TODO(), tt.fields.keyToType) - - handler := TypeMappingHandler(tt.args.handler(t)) - err := handler(ctx, ConsumableEvent[json.RawMessage]{ - Payload: tt.args.msg, - DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, - }) - if !tt.wantErr(t, err) { - return - } - }) - } -} diff --git a/consumer.go b/consumer.go index 0c68c30..370ce07 100644 --- a/consumer.go +++ b/consumer.go @@ -1,183 +1,106 @@ -// MIT License -// -// Copyright (c) 2024 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" + "time" amqp "github.com/rabbitmq/amqp091-go" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/trace" ) -type ( - // Handler is the type definition for a function that is used to handle events that has been mapped with - // RoutingKey <-> Type mappings from WithTypeMapping. - // If processing fails, an error should be returned and the message will be re-queued - Handler func(ctx context.Context, event ConsumableEvent[any]) error - // EventHandler is the type definition for 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 the type definition for 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) -) +type queueConsumer struct { + queue string + handlers routingKeyHandler + routingKeyToType routingKeyToType + notificationCh chan<- Notification +} -// TypeMappingHandler wraps a Handler func into an EventHandler in order to use it with the different -// Consumer Setup func. -// It will use the mappings from WithTypeMapping to determine routing key -> actual event type and pass it to the -// handler func. -func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { - return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { - message, exists := routingKeyToTypeFromContext(ctx, event) - if !exists { - return ErrNoMessageTypeForRouteKey - } - if err := json.Unmarshal(event.Payload, &message); err != nil { - return err - } - msg := ConsumableEvent[any]{ - Metadata: event.Metadata, - DeliveryInfo: event.DeliveryInfo, - Payload: message, - } - return handler(ctx, msg) +func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification) error { + deliveries, err := channel.Consume(c.queue, "", false, false, false, false, nil) + if err != nil { + return err } -} + c.routingKeyToType = routingKeyToType + c.notificationCh = notificationCh + go c.loop(deliveries) -// 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 ...QueueBindingConfigSetup) Setup { - return StreamConsumer(defaultEventExchangeName, routingKey, handler, opts...) + return nil } -// 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]) Setup { - return func(c *Connection) error { - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceResponseQueueName(targetService, c.serviceName), - exchangeName: serviceResponseExchangeName(targetService), - kind: kindHeaders, - headers: amqp.Table{headerService: c.serviceName}, +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 } - - return c.messageHandlerBindQueueToExchange(config) + c.handleDelivery(handler, delivery, deliveryInfo) } } -// 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]) Setup { - return func(c *Connection) error { - resExchangeName := serviceResponseExchangeName(c.serviceName) - if err := exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { - return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) - } - - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceRequestQueueName(c.serviceName), - exchangeName: serviceRequestExchangeName(c.serviceName), - kind: kindDirect, - } - - return c.messageHandlerBindQueueToExchange(config) +func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Delivery, deliveryInfo DeliveryInfo) { + tracingCtx := extractToContext(delivery.Headers) + span := trace.SpanFromContext(tracingCtx) + if !span.SpanContext().IsValid() { + tracingCtx, span = otel.Tracer("amqp").Start(context.Background(), fmt.Sprintf("%s#%s", deliveryInfo.Queue, delivery.RoutingKey)) } -} - -// StreamConsumer sets up ap a durable, persistent event stream consumer. -func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...QueueBindingConfigSetup) Setup { - exchangeName := topicExchangeName(exchange) - return func(c *Connection) error { - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceEventQueueName(exchangeName, c.serviceName), - exchangeName: exchangeName, - kind: kindTopic, + defer span.End() + handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.routingKeyToType) + startTime := time.Now() + + uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} + if err := handler(handlerCtx, uevt); err != nil { + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, 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) } - 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) + return } -} -// 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]) Setup { - return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) + elapsed := time.Since(startTime).Milliseconds() + notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) + _ = delivery.Ack(false) + eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) } -// 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]) Setup { - exchangeName := topicExchangeName(exchange) +type queueConsumers map[string]*queueConsumer - return func(c *Connection) error { - queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) - config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: queueName, - exchangeName: exchangeName, - kind: kindTopic, - transient: true, - } - return c.messageHandlerBindQueueToExchange(config) +func (c *queueConsumers) get(queueName, routingKey string) (wrappedHandler, bool) { + consumerForQueue, ok := (*c)[queueName] + if !ok { + return nil, false } + return consumerForQueue.handlers.get(routingKey) } -// Handles WithTypeMapping mappings in context.Context -type routingKeyToTypeCtx string - -const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" - -func injectRoutingKeyToTypeContext(ctx context.Context, keyToType RoutingKeyToType) context.Context { - return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) -} - -func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { - routingKey := event.DeliveryInfo.RoutingKey - keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(RoutingKeyToType) +func (c *queueConsumers) add(queueName, routingKey string, handler wrappedHandler) error { + consumerForQueue, ok := (*c)[queueName] if !ok { - return nil, false + consumerForQueue = &queueConsumer{ + queue: queueName, + handlers: make(routingKeyHandler), + } + (*c)[queueName] = consumerForQueue } - - typ, exists := keyToType[routingKey] - if !exists { - return nil, false + if mappedRoutingKey, exists := consumerForQueue.handlers.exists(routingKey); exists { + return fmt.Errorf("routingkey %s overlaps %s for queue %s, consider using AddQueueNameSuffix", routingKey, mappedRoutingKey, queueName) } - return reflect.New(typ).Interface(), true + consumerForQueue.handlers.add(routingKey, handler) + return nil } diff --git a/handler.go b/handler.go index a8b9d81..ec81025 100644 --- a/handler.go +++ b/handler.go @@ -27,79 +27,9 @@ import ( "encoding/json" "errors" "fmt" - "regexp" - "strings" ) -// handlers holds the handlers for a certain queue -type handlers map[string]wrappedHandler - -// get returns the handler for the given queue and routing key that matches -func (h *handlers) 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 matches function to support wildcards) -func (h *handlers) exists(routingKey string) (string, bool) { - for mappedRoutingKey := range *h { - if overlaps(routingKey, mappedRoutingKey) { - return mappedRoutingKey, true - } - } - return "", false -} - -func (h *handlers) add(routingKey string, handler wrappedHandler) { - (*h)[routingKey] = handler -} - -// queueHandlers holds all handlers for all queues -type queueHandlers map[string]*handlers - -// add a handler for the given queue and routing key -func (h *queueHandlers) add(queueName, routingKey string, handler wrappedHandler) error { - queueHandlers, ok := (*h)[queueName] - if !ok { - queueHandlers = &handlers{} - (*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 queueWithHandlers struct { - Name string - Handlers *handlers -} - -// queues returns all queue names for which we have added a handler -func (h *queueHandlers) queues() []queueWithHandlers { - if h == nil { - return []queueWithHandlers{} - } - var res []queueWithHandlers - for q, h := range *h { - res = append(res, queueWithHandlers{Name: q, Handlers: h}) - } - return res -} - -// handlers returns all the handlers for a given queue, keyed by the routing key -func (h *queueHandlers) handlers(queueName string) *handlers { - if handlers, ok := (*h)[queueName]; ok { - return handlers - } - return &handlers{} -} +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 @@ -118,35 +48,3 @@ func newWrappedHandler[T any](handler EventHandler[T]) wrappedHandler { return handler(ctx, consumableEvent) } } - -var ErrParseJSON = errors.New("failed to parse") - -// overlaps checks if two AMQP binding patterns overlap -func overlaps(p1, p2 string) bool { - if p1 == p2 { - return true - } else if match(p1, p2) { - return true - } else if match(p2, p1) { - return true - } - return false -} - -// match returns true if the AMQP binding pattern is matching the routing key -func match(pattern string, routingKey string) bool { - b, err := regexp.MatchString(fixRegex(pattern), routingKey) - if err != nil { - return false - } - return b -} - -// fixRegex converts the AMQP binding key syntax to regular expression -// For example: -// user.* => user\.[^.]* -// user.# => user\..* -func fixRegex(s string) string { - replace := strings.Replace(strings.Replace(strings.Replace(s, ".", "\\.", -1), "*", "[^.]*", -1), "#", ".*", -1) - return fmt.Sprintf("^%s$", replace) -} diff --git a/headers.go b/headers.go index d221694..e82e3ec 100644 --- a/headers.go +++ b/headers.go @@ -34,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 @@ -47,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 { diff --git a/headers_test.go b/headers_test.go index 7ac5d77..26df7bd 100644 --- a/headers_test.go +++ b/headers_test.go @@ -45,7 +45,7 @@ func Test_Headers(t *testing.T) { 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{headerService: "aService"} require.Equal(t, h.Get(headerService), "aService") diff --git a/metrics.go b/metrics.go index 34ccee3..8ee8b09 100644 --- a/metrics.go +++ b/metrics.go @@ -29,10 +29,10 @@ import ( ) const ( - queue = "queue" - exchange = "exchange" - result = "result" - routingKey = "routing_key" + metricQueue = "queue" + metricExchange = "exchange" + metricResult = "result" + metricRoutingKey = "routing_key" ) var ( @@ -40,35 +40,35 @@ var ( prometheus.CounterOpts{ Name: "amqp_events_received", Help: "Count of AMQP events received", - }, []string{queue, routingKey}, + }, []string{metricQueue, metricRoutingKey}, ) eventWithoutHandlerCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "amqp_events_without_handler", Help: "Count of AMQP events without a handler", - }, []string{queue, routingKey}, + }, []string{metricQueue, metricRoutingKey}, ) eventNotParsableCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "amqp_events_not_parsable", Help: "Count of AMQP events that could not be parsed", - }, []string{queue, routingKey}, + }, []string{metricQueue, metricRoutingKey}, ) eventNackCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "amqp_events_nack", Help: "Count of AMQP events that were not acknowledged", - }, []string{queue, routingKey}, + }, []string{metricQueue, metricRoutingKey}, ) eventAckCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "amqp_events_ack", Help: "Count of AMQP events that were acknowledged", - }, []string{queue, routingKey}, + }, []string{metricQueue, metricRoutingKey}, ) eventProcessedDuration = prometheus.NewHistogramVec( @@ -76,21 +76,21 @@ var ( Name: "amqp_events_processed_duration", Help: "Milliseconds taken to process an event", Buckets: []float64{100, 200, 500, 1000, 3000, 5000, 10000}, - }, []string{queue, routingKey, result}, + }, []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{exchange, routingKey}, + }, []string{metricExchange, metricRoutingKey}, ) eventPublishFailedCounter = prometheus.NewCounterVec( prometheus.CounterOpts{ Name: "amqp_events_publish_failed", Help: "Count of AMQP events that could not be published", - }, []string{exchange, routingKey}, + }, []string{metricExchange, metricRoutingKey}, ) ) diff --git a/must_test.go b/must_test.go new file mode 100644 index 0000000..8bafd0c --- /dev/null +++ b/must_test.go @@ -0,0 +1,41 @@ +// MIT License +// +// Copyright (c) 2024 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_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/queue_binding_config.go b/queue_binding_config.go index 8215c04..21de638 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -24,7 +24,7 @@ type QueueBindingConfig struct { } // AddQueueNameSuffix appends the provided suffix to the queue name -// Useful when multiple consumers are needed for a routing key in the same service +// Useful when multiple queueConsumers are needed for a routing key in the same service func AddQueueNameSuffix(suffix string) QueueBindingConfigSetup { return func(config *QueueBindingConfig) error { if suffix == "" { diff --git a/queue_binding_config_test.go b/queue_binding_config_test.go new file mode 100644 index 0000000..3383de9 --- /dev/null +++ b/queue_binding_config_test.go @@ -0,0 +1,39 @@ +// MIT License +// +// Copyright (c) 2024 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("")(&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) +} diff --git a/request_response_test.go b/request_response_test.go index cd4bf2b..84b78a0 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -57,9 +57,8 @@ func Test_RequestResponseHandler(t *testing.T) { 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: nil}, channel.BindingDeclarations[0]) - require.Equal(t, 1, len(conn.queueHandlers.queues())) - - handler, _ := conn.queueHandlers.handlers("svc.direct.exchange.request.queue").get("key") + require.Len(t, *conn.queueConsumers, 1) + handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) msg, _ := json.Marshal(Message{Ok: true}) diff --git a/routingkey_handlers.go b/routingkey_handlers.go new file mode 100644 index 0000000..72a8b19 --- /dev/null +++ b/routingkey_handlers.go @@ -0,0 +1,64 @@ +package goamqp + +import ( + "fmt" + "regexp" + "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 { + return true + } else if match(p1, p2) { + return true + } else if match(p2, p1) { + return true + } + return false +} + +// match returns true if the AMQP binding pattern is matching the routing key +func match(pattern string, routingKey string) bool { + b, err := regexp.MatchString(fixRegex(pattern), routingKey) + if err != nil { + return false + } + return b +} + +// fixRegex converts the AMQP binding key syntax to regular expression +// For example: +// user.* => user\.[^.]* +// user.# => user\..* +func fixRegex(s string) string { + 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..c5f891f --- /dev/null +++ b/routingkey_handlers_test.go @@ -0,0 +1,91 @@ +// MIT License +// +// Copyright (c) 2024 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 index 7c5d036..61d3e96 100644 --- a/setup.go +++ b/setup.go @@ -31,10 +31,6 @@ import ( amqp "github.com/rabbitmq/amqp091-go" ) -type RoutingKeyToType map[string]reflect.Type - -type TypeToRoutingKey map[reflect.Type]string - // 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 @@ -56,7 +52,7 @@ func WithTypeMapping(routingKey string, msgType any) Setup { } // WithPrefetchLimit configures the number of messages to prefetch from the server. -// To get round-robin behavior between consumers consuming from the same queue on +// 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 diff --git a/setup_consumer.go b/setup_consumer.go new file mode 100644 index 0000000..770dfeb --- /dev/null +++ b/setup_consumer.go @@ -0,0 +1,183 @@ +// MIT License +// +// Copyright (c) 2024 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 ( + // Handler is the type definition for a function that is used to handle events that has been mapped with + // RoutingKey <-> Type mappings from WithTypeMapping. + // If processing fails, an error should be returned and the message will be re-queued + Handler func(ctx context.Context, event ConsumableEvent[any]) error + // EventHandler is the type definition for 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 the type definition for 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 Handler func into an EventHandler in order to use it with the different +// Consumer Setup func. +// It will use the mappings from WithTypeMapping to determine routing key -> actual event type and pass it to the +// handler func. +func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { + return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { + message, exists := routingKeyToTypeFromContext(ctx, event) + if !exists { + return ErrNoMessageTypeForRouteKey + } + if err := json.Unmarshal(event.Payload, &message); err != nil { + return err + } + msg := ConsumableEvent[any]{ + Metadata: event.Metadata, + DeliveryInfo: event.DeliveryInfo, + Payload: message, + } + return handler(ctx, msg) + } +} + +// 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 ...QueueBindingConfigSetup) 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]) Setup { + return func(c *Connection) error { + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + 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[T any](routingKey string, handler EventHandler[T]) Setup { + return func(c *Connection) error { + resExchangeName := serviceResponseExchangeName(c.serviceName) + if err := exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { + return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) + } + + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: serviceRequestQueueName(c.serviceName), + exchangeName: serviceRequestExchangeName(c.serviceName), + kind: kindDirect, + } + + 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 ...QueueBindingConfigSetup) Setup { + exchangeName := topicExchangeName(exchange) + return func(c *Connection) error { + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: serviceEventQueueName(exchangeName, c.serviceName), + exchangeName: exchangeName, + kind: kindTopic, + } + 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) + } +} + +// 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]) Setup { + return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) +} + +// 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]) Setup { + exchangeName := topicExchangeName(exchange) + + return func(c *Connection) error { + queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) + config := &QueueBindingConfig{ + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: queueName, + exchangeName: exchangeName, + kind: kindTopic, + transient: true, + } + return c.messageHandlerBindQueueToExchange(config) + } +} + +// Handles WithTypeMapping mappings in context.Context +type routingKeyToTypeCtx string + +const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" + +func injectRoutingKeyToTypeContext(ctx context.Context, keyToType routingKeyToType) context.Context { + return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) +} + +func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { + routingKey := event.DeliveryInfo.RoutingKey + keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(routingKeyToType) + if !ok { + return nil, false + } + + typ, exists := keyToType[routingKey] + if !exists { + return nil, false + } + return reflect.New(typ).Interface(), true +} diff --git a/consumer_test.go b/setup_consumer_test.go similarity index 71% rename from consumer_test.go rename to setup_consumer_test.go index c6e8ddb..3d0bf55 100644 --- a/consumer_test.go +++ b/setup_consumer_test.go @@ -24,11 +24,15 @@ package goamqp import ( "context" + "encoding/json" "errors" + "fmt" + "reflect" "testing" "github.com/google/uuid" amqp "github.com/rabbitmq/amqp091-go" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -44,7 +48,6 @@ func Test_Consumer_Setups(t *testing.T) { expectedQueues []QueueDeclaration expectedBindings []BindingDeclaration expectedConsumer []Consumer - expectedHandler *queueHandlers }{ { name: "EventStreamConsumer", @@ -95,9 +98,6 @@ func Test_Consumer_Setups(t *testing.T) { expectedQueues: []QueueDeclaration{{name: "targetService.headers.exchange.response.queue.svc", noWait: false, autoDelete: false, durable: true, args: amqp.Table{"x-expires": 432000000}}}, 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}}, - expectedHandler: &queueHandlers{"targetService.headers.exchange.response.queue.svc": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { - return nil - }}}, }, { name: "TransientEventStreamConsumer", @@ -108,9 +108,6 @@ func Test_Consumer_Setups(t *testing.T) { expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: nil}}, expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, - expectedHandler: &queueHandlers{"events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f": &handlers{"key": func(ctx context.Context, event unmarshalEvent) error { - return nil - }}}, }, { name: "routing key already exists", @@ -161,3 +158,136 @@ func Test_Consumer_Setups(t *testing.T) { }) } } + +func Test_MappingsInContext(t *testing.T) { + mappings := routingKeyToType{ + "string": reflect.TypeOf(""), + "double": reflect.TypeOf(1.0), + } + rootCtx := context.TODO() + ctx := injectRoutingKeyToTypeContext(rootCtx, mappings) + instance, ok := routingKeyToTypeFromContext(ctx, ConsumableEvent[any]{ + DeliveryInfo: DeliveryInfo{RoutingKey: "string"}, + }) + require.True(t, ok) + require.IsType(t, reflect.TypeOf(instance), reflect.TypeOf("")) + + _, ok = routingKeyToTypeFromContext(ctx, ConsumableEvent[any]{ + DeliveryInfo: DeliveryInfo{RoutingKey: "int"}, + }) + require.False(t, ok) + + // This should always fail + _, ok = routingKeyToTypeFromContext(rootCtx, ConsumableEvent[any]{ + DeliveryInfo: DeliveryInfo{RoutingKey: "string"}, + }) + require.False(t, ok) +} + +func Test_TypeMappingHandler(t *testing.T) { + type fields struct { + keyToType map[string]reflect.Type + } + type args struct { + handler func(t *testing.T) Handler + msg json.RawMessage + key string + } + tests := []struct { + name string + fields fields + args args + wantErr assert.ErrorAssertionFunc + }{ + { + name: "no mapped type, ignored", + fields: fields{}, + args: args{ + msg: []byte(`{"a":true}`), + key: "unknown", + handler: func(t *testing.T) Handler { + return func(ctx context.Context, event ConsumableEvent[any]) error { + return nil + } + }, + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return assert.ErrorIs(t, err, ErrNoMessageTypeForRouteKey) + }, + }, + { + 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) Handler { + return func(ctx context.Context, event ConsumableEvent[any]) error { + return 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) Handler { + return func(ctx context.Context, event ConsumableEvent[any]) error { + assert.IsType(t, &TestMessage{}, event.Payload) + 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{ + keyToType: map[string]reflect.Type{ + "known": reflect.TypeOf(TestMessage{}), + }, + }, + args: args{ + msg: []byte(`{"a":true}`), + key: "known", + handler: func(t *testing.T) Handler { + return func(ctx context.Context, event ConsumableEvent[any]) error { + assert.IsType(t, &TestMessage{}, event.Payload) + return nil + } + }, + }, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := injectRoutingKeyToTypeContext(context.TODO(), tt.fields.keyToType) + + handler := TypeMappingHandler(tt.args.handler(t)) + err := handler(ctx, ConsumableEvent[json.RawMessage]{ + Payload: tt.args.msg, + DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, + }) + if !tt.wantErr(t, err) { + return + } + }) + } +} diff --git a/publisher.go b/setup_publisher.go similarity index 79% rename from publisher.go rename to setup_publisher.go index 1554ec8..50afdef 100644 --- a/publisher.go +++ b/setup_publisher.go @@ -33,7 +33,7 @@ import ( // Publisher is used to send messages type Publisher struct { - typeToKey TypeToRoutingKey + typeToKey typeToRoutingKey channel AmqpChannel exchange string defaultHeaders []Header @@ -82,18 +82,12 @@ func EventStreamPublisher(publisher *Publisher) Setup { // StreamPublisher sets up an event stream publisher func StreamPublisher(exchange string, publisher *Publisher) Setup { - name := topicExchangeName(exchange) + exchangeName := topicExchangeName(exchange) return func(c *Connection) error { - if err := exchangeDeclare(c.channel, name, kindTopic); err != nil { - return fmt.Errorf("failed to declare exchange %s, %w", name, err) + if err := exchangeDeclare(c.channel, exchangeName, kindTopic); err != nil { + return fmt.Errorf("failed to declare exchange %s, %w", exchangeName, err) } - publisher.channel = c.channel - publisher.typeToKey = c.typeToKey - if err := publisher.setDefaultHeaders(c.serviceName); err != nil { - return err - } - publisher.exchange = name - return nil + return publisher.setup(c.channel, c.serviceName, exchangeName, c.typeToKey) } } @@ -102,42 +96,31 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { // 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.channel = c.channel - publisher.typeToKey = c.typeToKey - if err := publisher.setDefaultHeaders(c.serviceName, - Header{Key: "CC", Value: []any{destinationQueueName}}, - ); err != nil { - return err - } - publisher.exchange = "" - return nil + return publisher.setup(c.channel, c.serviceName, "", c.typeToKey, 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 { - reqExchangeName := serviceRequestExchangeName(targetService) - publisher.channel = c.channel - publisher.typeToKey = c.typeToKey - if err := publisher.setDefaultHeaders(c.serviceName); err != nil { - return err - } - publisher.exchange = reqExchangeName - if err := exchangeDeclare(c.channel, reqExchangeName, kindDirect); err != nil { + if err := exchangeDeclare(c.channel, exchangeName, kindDirect); err != nil { return err } - return nil + return publisher.setup(c.channel, c.serviceName, exchangeName, c.typeToKey) } } -func (p *Publisher) setDefaultHeaders(serviceName string, headers ...Header) error { +func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, typeToKey typeToRoutingKey, 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.typeToKey = typeToKey + p.exchange = exchange return nil } diff --git a/publisher_test.go b/setup_publisher_test.go similarity index 97% rename from publisher_test.go rename to setup_publisher_test.go index c0b2ea4..0807983 100644 --- a/publisher_test.go +++ b/setup_publisher_test.go @@ -212,3 +212,8 @@ func Test_Publisher_Setups(t *testing.T) { }) } } + +func Test_InvalidHeader(t *testing.T) { + err := (&Publisher{}).setup(nil, "", "", nil, Header{Key: "", Value: ""}) + require.ErrorIs(t, err, ErrEmptyHeaderKey) +} diff --git a/setup_test.go b/setup_test.go index 541aa19..2c53051 100644 --- a/setup_test.go +++ b/setup_test.go @@ -23,6 +23,7 @@ package goamqp import ( + "context" "errors" "testing" @@ -102,3 +103,32 @@ func Test_WithTypeMapping_TypeAlreadyExist(t *testing.T) { err = WithTypeMapping("key", TestMessage{})(conn) require.NoError(t, err) } + +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{}, + } + err := conn.Start(context.Background(), + WithPrefetchLimit(1), + ) + require.NoError(t, err) +} From edf5b552ce3c0b179c3b39a1172c3808754c9efc Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 14:27:40 +0100 Subject: [PATCH 22/50] chore: tests --- connection.go | 20 ++------ connection_test.go | 19 -------- consumer.go | 22 ++++++--- consumer_test.go | 118 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 consumer_test.go diff --git a/connection.go b/connection.go index af961af..766d31c 100644 --- a/connection.go +++ b/connection.go @@ -177,22 +177,8 @@ func (c *Connection) connectToAmqpURL() error { return nil } -func (c *Connection) addHandler(queueName, routingKey string, handler wrappedHandler) error { - return c.queueConsumers.add(queueName, routingKey, handler) -} - -func getDeliveryInfo(queueName string, delivery amqp.Delivery) DeliveryInfo { - deliveryInfo := DeliveryInfo{ - Queue: queueName, - Exchange: delivery.Exchange, - RoutingKey: delivery.RoutingKey, - Headers: Headers(delivery.Headers), - } - return deliveryInfo -} - func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) error { - if err := c.addHandler(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { + if err := c.queueConsumers.add(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err } @@ -246,8 +232,10 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { func (c *Connection) setup() error { for _, consumer := range *c.queueConsumers { - if err := consumer.consume(c.channel, c.keyToType, c.notificationCh); err != nil { + if deliveries, err := consumer.consume(c.channel, c.keyToType, c.notificationCh); err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", consumer.queue, err) + } else { + go consumer.loop(deliveries) } } return nil diff --git a/connection_test.go b/connection_test.go index 4985040..ce91b48 100644 --- a/connection_test.go +++ b/connection_test.go @@ -334,25 +334,6 @@ func TestResponseWrapper(t *testing.T) { } } -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, - queueName: "queue", - exchangeName: "exchange", - kind: kindDirect, - headers: nil, - } - err := conn.messageHandlerBindQueueToExchange(cfg) - require.EqualError(t, err, "failed to create queue") -} - func Test_Publisher_ReservedHeader(t *testing.T) { p := NewPublisher() err := p.Publish(context.Background(), TestMessage{Msg: "test"}, Header{"service", "header"}) diff --git a/consumer.go b/consumer.go index 370ce07..c6218e4 100644 --- a/consumer.go +++ b/consumer.go @@ -18,16 +18,14 @@ type queueConsumer struct { notificationCh chan<- Notification } -func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification) error { +func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification) (<-chan amqp.Delivery, error) { + c.routingKeyToType = routingKeyToType + c.notificationCh = notificationCh deliveries, err := channel.Consume(c.queue, "", false, false, false, false, nil) if err != nil { - return err + return nil, err } - c.routingKeyToType = routingKeyToType - c.notificationCh = notificationCh - go c.loop(deliveries) - - return nil + return deliveries, nil } func (c *queueConsumer) loop(deliveries <-chan amqp.Delivery) { @@ -104,3 +102,13 @@ func (c *queueConsumers) add(queueName, routingKey string, handler wrappedHandle 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..35cdad2 --- /dev/null +++ b/consumer_test.go @@ -0,0 +1,118 @@ +// MIT License +// +// Copyright (c) 2024 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_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{}, + } + 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 delivery(acker MockAcknowledger, routingKey string, success bool) amqp.Delivery { + body, _ := json.Marshal(Message{success}) + + return amqp.Delivery{ + Body: body, + RoutingKey: routingKey, + Acknowledger: &acker, + } +} From 364eff78e1ee72af9c59848ca7f27dbefeb8c4a4 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 15:52:18 +0100 Subject: [PATCH 23/50] chore: amqp url for integration tests --- integration/integration_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 465d57d..2677a11 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -41,7 +41,7 @@ import ( var ( serverServiceName = "server" - amqpURL = "amqp://user:password@localhost:5672/test" + amqpURL = "amqp://user:password@localhost:5672" ) type IntegrationTestSuite struct { From ab3c75f942751e9f85d0ee426de58f69258d3119 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 15:55:16 +0100 Subject: [PATCH 24/50] chore: license headers --- consumer.go | 22 ++++++++++++++++++++++ integration/messages.go | 22 ++++++++++++++++++++++ queue_binding_config.go | 22 ++++++++++++++++++++++ routingkey_handlers.go | 22 ++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/consumer.go b/consumer.go index c6218e4..8cabe66 100644 --- a/consumer.go +++ b/consumer.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( diff --git a/integration/messages.go b/integration/messages.go index 0989ab0..0aa4491 100644 --- a/integration/messages.go +++ b/integration/messages.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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. + //go:build integration // +build integration diff --git a/queue_binding_config.go b/queue_binding_config.go index 21de638..3cc329c 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( diff --git a/routingkey_handlers.go b/routingkey_handlers.go index 72a8b19..8251970 100644 --- a/routingkey_handlers.go +++ b/routingkey_handlers.go @@ -1,3 +1,25 @@ +// MIT License +// +// Copyright (c) 2024 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 ( From b6bd3e6891b606ddef9007045e7da0291d351619 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 9 Feb 2024 15:58:56 +0100 Subject: [PATCH 25/50] chore: expose admin port --- .github/workflows/tests.yml | 3 ++- example_test.go | 2 +- examples/event-stream/example_test.go | 3 +-- examples/request-response/example_test.go | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3d806a8..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 diff --git a/example_test.go b/example_test.go index dbbffff..40b3380 100644 --- a/example_test.go +++ b/example_test.go @@ -29,7 +29,7 @@ import ( "time" ) -var amqpURL = "amqp://user:password@localhost:5672/" +var amqpURL = "amqp://user:password@localhost:5672" func Example() { ctx := context.Background() diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 8a3b3e1..91099fb 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -31,8 +31,7 @@ import ( "github.com/sparetimecoders/goamqp" ) -// var amqpURL = "amqp://user:password@localhost:5672/test" -var amqpURL = "amqp://user:password@localhost:5672/test" +var amqpURL = "amqp://user:password@localhost:5672" func ExampleEventStream() { ctx := context.Background() diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index d649e1a..49b831d 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -31,7 +31,7 @@ import ( "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() From 0fc5638eb2b711bfcdef5924fec8933848e6a4d3 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Sat, 10 Feb 2024 12:11:01 +0100 Subject: [PATCH 26/50] chore: correct error returned on failure to parse JSON --- setup_consumer.go | 2 +- setup_consumer_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/setup_consumer.go b/setup_consumer.go index 770dfeb..2fde4d3 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -56,7 +56,7 @@ func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { return ErrNoMessageTypeForRouteKey } if err := json.Unmarshal(event.Payload, &message); err != nil { - return err + return fmt.Errorf("%v: %w", err, ErrParseJSON) } msg := ConsumableEvent[any]{ Metadata: event.Metadata, diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 3d0bf55..9d8f2fd 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -232,7 +232,7 @@ func Test_TypeMappingHandler(t *testing.T) { }, }, wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { - return assert.EqualError(t, err, "unexpected end of JSON input") + return assert.ErrorContains(t, err, "unexpected end of JSON input") }, }, { From e896afe6f7ee89c22c584a67be5b9b86caddca66 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Sat, 10 Feb 2024 14:44:37 +0100 Subject: [PATCH 27/50] chore: test pre-commit --- .pre-commit-config.yaml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6cddab7..b24e99f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -28,11 +28,15 @@ repos: rev: v1.5.4 hooks: - id: insert-license - files: \_test.go$ + files: \.go$ args: - --license-filepath - - LICENSE # defaults to: LICENSE.txt + - LICENSE - --comment-style - - // # defaults to: # + - // - --use-current-year - - --no-extra-eol + - repo: https://github.com/sparetimecoders/pre-commit-check-signed + rev: v0.0.1 + hooks: + - id: check-signed-commit + name: check-signed-commit From d512894e77f80cd491a004e1a667817fdf9a7565 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Sat, 10 Feb 2024 15:32:45 +0100 Subject: [PATCH 28/50] chore: tests --- connection_test.go | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/connection_test.go b/connection_test.go index ce91b48..54e9470 100644 --- a/connection_test.go +++ b/connection_test.go @@ -76,6 +76,51 @@ func Test_Start_SettingDefaultQosFails(t *testing.T) { require.EqualError(t, err, "error setting qos") } +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"), + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + channel := &MockAmqpChannel{ + QueueDeclarationError: &tt.queueDeclarationError, + ExchangeDeclarationError: &tt.exchangeDeclarationError, + } + conn := mockConnection(channel) + cfg := &QueueBindingConfig{ + routingKey: "routingkey", + handler: nil, + queueName: "queue", + exchangeName: "exchange", + kind: kindDirect, + headers: 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) + } + }) + } +} + func Test_Start_SetupFails(t *testing.T) { mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} mockChannel := &MockAmqpChannel{ From 2b95a8c2d42b65e8c20d86f780510b2edcc229f5 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Sun, 11 Feb 2024 16:36:49 +0100 Subject: [PATCH 29/50] chore: more tests --- connection_test.go | 6 ++++ consumer_test.go | 64 ++++++++++++++++++++++++++++++++++++++++ mocks_test.go | 12 -------- request_response_test.go | 3 ++ setup_test.go | 3 ++ 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/connection_test.go b/connection_test.go index 54e9470..43060b3 100644 --- a/connection_test.go +++ b/connection_test.go @@ -268,6 +268,12 @@ func Test_ExchangeDeclare(t *testing.T) { require.Equal(t, ExchangeDeclaration{name: "name", kind: "topic", durable: true, autoDelete: false, noWait: false, args: nil}, channel.ExchangeDeclarations[0]) } +func Test_Publish_Fail(t *testing.T) { + channel := NewMockAmqpChannel() + 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{} diff --git a/consumer_test.go b/consumer_test.go index 35cdad2..d5523ef 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -33,6 +33,14 @@ import ( "github.com/stretchr/testify/require" ) +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", @@ -107,6 +115,62 @@ func Test_ConsumerLoop(t *testing.T) { require.Len(t, acker.Acks, 2) } +func Test_HandleDelivery(t *testing.T) { + tests := []struct { + name string + error error + numberOfAcks int + numberOfNacks int + numberOfRejects int + notification string + }{ + { + name: "ok", + notification: "event handler for key succeeded", + numberOfAcks: 1, + }, + { + name: "invalid JSON", + error: ErrParseJSON, + notification: "error: failed to parse", + numberOfNacks: 1, + }, + { + name: "no match for routingkey", + error: ErrNoMessageTypeForRouteKey, + notification: "error: no message type for routingkey configured", + numberOfRejects: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + notifications := make(chan Notification, 1) + consumer := queueConsumer{ + notificationCh: notifications, + } + 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) + notification := <-notifications + require.Contains(t, notification.Message, tt.notification) + + 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}) diff --git a/mocks_test.go b/mocks_test.go index 75ed762..f6827c2 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -182,10 +182,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") @@ -237,14 +233,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{} diff --git a/request_response_test.go b/request_response_test.go index 84b78a0..275e7ed 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -60,6 +60,9 @@ func Test_RequestResponseHandler(t *testing.T) { require.Len(t, *conn.queueConsumers, 1) handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", 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{ diff --git a/setup_test.go b/setup_test.go index 2c53051..c5270ea 100644 --- a/setup_test.go +++ b/setup_test.go @@ -127,8 +127,11 @@ func Test_Start_WithPrefetchLimit_Resets_Qos(t *testing.T) { channel: mockChannel, queueConsumers: &queueConsumers{}, } + notifications := make(chan<- Notification) err := conn.Start(context.Background(), WithPrefetchLimit(1), + WithNotificationChannel(notifications), ) require.NoError(t, err) + require.Equal(t, notifications, conn.notificationCh) } From 0c8df6f919d479d3e4f37941a3a1cc0afb2d6c00 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 13 Feb 2024 10:07:51 +0100 Subject: [PATCH 30/50] chore: test metrics --- metrics_test.go | 49 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 metrics_test.go diff --git a/metrics_test.go b/metrics_test.go new file mode 100644 index 0000000..870de1e --- /dev/null +++ b/metrics_test.go @@ -0,0 +1,49 @@ +// MIT License +// +// Copyright (c) 2024 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) +} From 93303ab3a42e98af6c20e494e50cbf9c1da77031 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 13 Feb 2024 11:01:45 +0100 Subject: [PATCH 31/50] chore: updated some docs --- docs/README.md | 74 +++++++++++++++++++------------------- docs/message_processing.md | 44 +++++++++-------------- 2 files changed, 55 insertions(+), 63 deletions(-) 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 ce2d118..5b87fe6 100644 --- a/docs/message_processing.md +++ b/docs/message_processing.md @@ -1,46 +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 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 goamqp fails to unmarshal the JSON content in the message, the message will be rejected and **not** requeued again. +If unmarshal the JSON payload in the event, the event will be rejected but **not** re-queued again. From e67b5968c14d2162eb3b90f0d6cb28d876768a73 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 13 Feb 2024 11:05:35 +0100 Subject: [PATCH 32/50] chore: naming for anonymous functions changed --- request_response_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/request_response_test.go b/request_response_test.go index 275e7ed..1a48564 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -59,7 +59,7 @@ func Test_RequestResponseHandler(t *testing.T) { require.Len(t, *conn.queueConsumers, 1) handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") - require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].func1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) missing, exists := conn.queueConsumers.get("miggins", "key") require.Nil(t, missing) require.False(t, exists) From 664317f7492eb93512e03edbac0a10ee2082617c Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 20 Nov 2024 22:08:31 +0100 Subject: [PATCH 33/50] chore: merged --- connection.go | 1 + 1 file changed, 1 insertion(+) diff --git a/connection.go b/connection.go index 766d31c..3fb3e86 100644 --- a/connection.go +++ b/connection.go @@ -24,6 +24,7 @@ package goamqp import ( "context" + "errors" "fmt" "io" "os" From 9de95df804147dc3e034ab50e12d17184bd671c9 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 20 Nov 2024 22:58:41 +0100 Subject: [PATCH 34/50] chore: split notification channels --- connection.go | 3 ++- consumer.go | 6 ++++-- consumer_test.go | 45 +++++++++++++++++++++++++----------------- notifications.go | 22 ++++++++------------- setup.go | 10 +++++++++- setup_consumer.go | 5 ++--- setup_consumer_test.go | 12 +++-------- setup_test.go | 2 ++ tracing.go | 2 +- 9 files changed, 58 insertions(+), 49 deletions(-) diff --git a/connection.go b/connection.go index 3fb3e86..1b2607d 100644 --- a/connection.go +++ b/connection.go @@ -46,6 +46,7 @@ type Connection struct { typeToKey typeToRoutingKey keyToType routingKeyToType notificationCh chan<- Notification + errorCh chan<- ErrorNotification } // ServiceResponsePublisher represents the function that is called to publish a response @@ -233,7 +234,7 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { func (c *Connection) setup() error { for _, consumer := range *c.queueConsumers { - if deliveries, err := consumer.consume(c.channel, c.keyToType, c.notificationCh); err != nil { + if deliveries, err := consumer.consume(c.channel, c.keyToType, 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) diff --git a/consumer.go b/consumer.go index 8cabe66..2c5e65d 100644 --- a/consumer.go +++ b/consumer.go @@ -38,11 +38,13 @@ type queueConsumer struct { handlers routingKeyHandler routingKeyToType routingKeyToType notificationCh chan<- Notification + errorCh chan<- ErrorNotification } -func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification) (<-chan amqp.Delivery, error) { +func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification, errorCh chan<- ErrorNotification) (<-chan amqp.Delivery, error) { c.routingKeyToType = routingKeyToType c.notificationCh = notificationCh + c.errorCh = errorCh deliveries, err := channel.Consume(c.queue, "", false, false, false, false, nil) if err != nil { return nil, err @@ -79,7 +81,7 @@ func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Del uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} if err := handler(handlerCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerFailed(c.notificationCh, deliveryInfo.RoutingKey, elapsed, err) + notifyEventHandlerFailed(c.errorCh, deliveryInfo.RoutingKey, elapsed, err) if errors.Is(err, ErrParseJSON) { eventNotParsable(deliveryInfo.Queue, deliveryInfo.RoutingKey) _ = delivery.Nack(false, false) diff --git a/consumer_test.go b/consumer_test.go index d5523ef..e16f2ea 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -62,7 +62,7 @@ func Test_Consume(t *testing.T) { return deliveries, nil }} - deliveries, err := consumer.consume(channel, nil, nil) + deliveries, err := consumer.consume(channel, nil, nil, nil) require.NoError(t, err) delivery := <-deliveries require.Equal(t, "MESSAGE_ID", delivery.MessageId) @@ -77,7 +77,7 @@ func Test_Consume_Failing(t *testing.T) { return nil, fmt.Errorf("failed") }} - _, err := consumer.consume(channel, nil, nil) + _, err := consumer.consume(channel, nil, nil, nil) require.EqualError(t, err, "failed") } @@ -117,12 +117,13 @@ func Test_ConsumerLoop(t *testing.T) { func Test_HandleDelivery(t *testing.T) { tests := []struct { - name string - error error - numberOfAcks int - numberOfNacks int - numberOfRejects int - notification string + name string + error error + numberOfAcks int + numberOfNacks int + numberOfRejects int + notification string + errorNotification string }{ { name: "ok", @@ -130,22 +131,24 @@ func Test_HandleDelivery(t *testing.T) { numberOfAcks: 1, }, { - name: "invalid JSON", - error: ErrParseJSON, - notification: "error: failed to parse", - numberOfNacks: 1, + name: "invalid JSON", + error: ErrParseJSON, + errorNotification: "error: failed to parse", + numberOfNacks: 1, }, { - name: "no match for routingkey", - error: ErrNoMessageTypeForRouteKey, - notification: "error: no message type for routingkey configured", - numberOfRejects: 1, + name: "no match for routingkey", + error: ErrNoMessageTypeForRouteKey, + errorNotification: "error: no message type for routingkey configured", + 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, } deliveryInfo := DeliveryInfo{ @@ -161,8 +164,14 @@ func Test_HandleDelivery(t *testing.T) { } d := delivery(acker, "routingKey", true) consumer.handleDelivery(handler, d, deliveryInfo) - notification := <-notifications - require.Contains(t, notification.Message, tt.notification) + if tt.notification != "" { + notification := <-notifications + require.Contains(t, notification.Message, tt.notification) + } + if tt.errorNotification != "" { + notification := <-errorNotifications + require.ErrorContains(t, notification.Error, tt.errorNotification) + } require.Len(t, acker.Acks, tt.numberOfAcks) require.Len(t, acker.Nacks, tt.numberOfNacks) diff --git a/notifications.go b/notifications.go index 3b179a6..c392c3c 100644 --- a/notifications.go +++ b/notifications.go @@ -30,35 +30,29 @@ const ( NotificationSourceConsumer NotificationSource = "CONSUMER" ) -type NotificationType string - -const ( - NotificationTypeInfo NotificationType = "INFO" - NotificationTypeError NotificationType = "ERROR" -) - type Notification struct { Message string - Type NotificationType Source NotificationSource } +type ErrorNotification struct { + Error error + Source NotificationSource +} func notifyEventHandlerSucceed(ch chan<- Notification, routingKey string, took int64) { if ch != nil { ch <- Notification{ - Type: NotificationTypeInfo, Message: fmt.Sprintf("event handler for %s succeeded, took %d milliseconds", routingKey, took), Source: NotificationSourceConsumer, } } } -func notifyEventHandlerFailed(ch chan<- Notification, routingKey string, took int64, err error) { +func notifyEventHandlerFailed(ch chan<- ErrorNotification, routingKey string, took int64, err error) { if ch != nil { - ch <- Notification{ - Type: NotificationTypeError, - Message: fmt.Sprintf("event handler for %s failed, took %d milliseconds, error: %s", routingKey, took, err), - Source: NotificationSourceConsumer, + ch <- ErrorNotification{ + Error: fmt.Errorf("event handler for %s failed, took %d milliseconds, error: %s", routingKey, took, err), + Source: NotificationSourceConsumer, } } } diff --git a/setup.go b/setup.go index 61d3e96..fe307d6 100644 --- a/setup.go +++ b/setup.go @@ -68,7 +68,7 @@ func WithPrefetchLimit(limit int) Setup { } // WithNotificationChannel specifies a go channel to receive messages -// such as connection established, reconnecting, event published, consumed, etc. +// such as connection event published, consumed, etc. func WithNotificationChannel(notificationCh chan<- Notification) Setup { return func(conn *Connection) error { conn.notificationCh = notificationCh @@ -76,6 +76,14 @@ func WithNotificationChannel(notificationCh chan<- Notification) Setup { } } +// 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 + } +} + // CloseListener receives a callback when the AMQP Channel gets closed func CloseListener(e chan error) Setup { return func(c *Connection) error { diff --git a/setup_consumer.go b/setup_consumer.go index 2fde4d3..d16f594 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -51,7 +51,7 @@ type ( // handler func. func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { - message, exists := routingKeyToTypeFromContext(ctx, event) + message, exists := routingKeyToTypeFromContext(ctx, event.DeliveryInfo.RoutingKey) if !exists { return ErrNoMessageTypeForRouteKey } @@ -168,8 +168,7 @@ func injectRoutingKeyToTypeContext(ctx context.Context, keyToType routingKeyToTy return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) } -func routingKeyToTypeFromContext[T any](ctx context.Context, event ConsumableEvent[T]) (any, bool) { - routingKey := event.DeliveryInfo.RoutingKey +func routingKeyToTypeFromContext(ctx context.Context, routingKey string) (any, bool) { keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(routingKeyToType) if !ok { return nil, false diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 9d8f2fd..01c19c0 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -166,21 +166,15 @@ func Test_MappingsInContext(t *testing.T) { } rootCtx := context.TODO() ctx := injectRoutingKeyToTypeContext(rootCtx, mappings) - instance, ok := routingKeyToTypeFromContext(ctx, ConsumableEvent[any]{ - DeliveryInfo: DeliveryInfo{RoutingKey: "string"}, - }) + instance, ok := routingKeyToTypeFromContext(ctx, "string") require.True(t, ok) require.IsType(t, reflect.TypeOf(instance), reflect.TypeOf("")) - _, ok = routingKeyToTypeFromContext(ctx, ConsumableEvent[any]{ - DeliveryInfo: DeliveryInfo{RoutingKey: "int"}, - }) + _, ok = routingKeyToTypeFromContext(ctx, "int") require.False(t, ok) // This should always fail - _, ok = routingKeyToTypeFromContext(rootCtx, ConsumableEvent[any]{ - DeliveryInfo: DeliveryInfo{RoutingKey: "string"}, - }) + _, ok = routingKeyToTypeFromContext(rootCtx, "string") require.False(t, ok) } diff --git a/setup_test.go b/setup_test.go index c5270ea..0af41a0 100644 --- a/setup_test.go +++ b/setup_test.go @@ -128,9 +128,11 @@ func Test_Start_WithPrefetchLimit_Resets_Qos(t *testing.T) { 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 index c59c9c7..65340a2 100644 --- a/tracing.go +++ b/tracing.go @@ -50,5 +50,5 @@ func extractToContext(headers amqp.Table) context.Context { } } - return otel.GetTextMapPropagator().Extract(context.TODO(), carrier) + return otel.GetTextMapPropagator().Extract(context.Background(), carrier) } From 47b058da013ffd674f00d50f6376644b9f40c310 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 20 Nov 2024 23:16:56 +0100 Subject: [PATCH 35/50] chore(doc): updated example docs --- examples/event-stream/README.md | 51 ++++++++++++++++------------- examples/request-response/README.md | 19 +++++------ 2 files changed, 37 insertions(+), 33 deletions(-) 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/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 From 04ca5c0a809bf9b7e10af1249117ce290679ecb7 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 20 Nov 2024 23:22:49 +0100 Subject: [PATCH 36/50] chore: update integration test --- integration/integration_test.go | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 2677a11..60de365 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -33,10 +33,11 @@ import ( "testing" "time" - . "github.com/sparetimecoders/goamqp" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.uber.org/goleak" + + . "github.com/sparetimecoders/goamqp" ) var ( @@ -99,7 +100,8 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { 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: "classic", }, AutoDelete: false, Durable: true, @@ -184,7 +186,8 @@ 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: "classic", }, AutoDelete: false, Durable: true, @@ -209,7 +212,8 @@ 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: "classic", }, AutoDelete: false, Durable: true, @@ -288,7 +292,8 @@ 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: "classic", }, AutoDelete: false, Durable: true, @@ -316,7 +321,8 @@ 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: "classic", }, AutoDelete: false, Durable: true, @@ -400,7 +406,8 @@ 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: "classic", }, AutoDelete: false, Durable: true, @@ -421,7 +428,8 @@ func (suite *IntegrationTestSuite) Test_EventStream() { } else { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XExpires: int(5 * 24 * time.Hour.Milliseconds()), + XQueueType: "classic", }, AutoDelete: true, Durable: false, @@ -526,7 +534,8 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { 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: "classic", }, AutoDelete: false, Durable: true, From 108daa139b513bbe7cdd291d11ebe79947426efa Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 21 Nov 2024 08:09:58 +0100 Subject: [PATCH 37/50] chore: fix integration test race condition --- integration/integration_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/integration/integration_test.go b/integration/integration_test.go index 60de365..f11a9c4 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -30,6 +30,7 @@ import ( "fmt" "os" "regexp" + "sync" "testing" "time" @@ -359,14 +360,19 @@ func (suite *IntegrationTestSuite) Test_EventStream() { ) defer server.Close() + mutex := sync.Mutex{} var received []any client1 := createConnection(suite, "client1", TransientEventStreamConsumer(routingKey1, func(ctx context.Context, msg ConsumableEvent[Incoming]) error { + mutex.Lock() + defer mutex.Unlock() received = append(received, msg.Payload) closer <- true 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 From 39f85a73630a73c9bc3f80145c6d696f945a8f9f Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 26 Nov 2024 09:36:52 +0100 Subject: [PATCH 38/50] chore: updates --- connection.go | 16 ++++++++++------ connection_test.go | 10 ++++++---- consumer.go | 26 +++++++++++++------------- consumer_test.go | 6 ++++++ naming.go | 5 +++++ request_response_test.go | 2 +- setup.go | 13 +++++++++++++ setup_consumer.go | 18 ++++++++++++++++++ setup_publisher.go | 6 ++++++ 9 files changed, 78 insertions(+), 24 deletions(-) diff --git a/connection.go b/connection.go index 1b2607d..e2972bc 100644 --- a/connection.go +++ b/connection.go @@ -47,6 +47,7 @@ type Connection struct { keyToType routingKeyToType notificationCh chan<- Notification errorCh chan<- ErrorNotification + spanNameFn func(DeliveryInfo) string } // ServiceResponsePublisher represents the function that is called to publish a response @@ -224,16 +225,19 @@ var ( func newConnection(serviceName string, uri amqp.URI) *Connection { return &Connection{ - serviceName: serviceName, - amqpUri: uri, - queueConsumers: &queueConsumers{}, - 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, + }, + keyToType: make(map[string]reflect.Type), + typeToKey: make(map[reflect.Type]string), } } func (c *Connection) setup() error { - for _, consumer := range *c.queueConsumers { + for _, consumer := range (*c).queueConsumers.consumers { if deliveries, err := consumer.consume(c.channel, c.keyToType, c.notificationCh, c.errorCh); err != nil { return fmt.Errorf("failed to create consumer for queue %s. %v", consumer.queue, err) } else { diff --git a/connection_test.go b/connection_test.go index 43060b3..2423235 100644 --- a/connection_test.go +++ b/connection_test.go @@ -129,10 +129,12 @@ func Test_Start_SetupFails(t *testing.T) { }, } conn := &Connection{ - serviceName: "test", - connection: mockAmqpConnection, - channel: mockChannel, - queueConsumers: &queueConsumers{}, + serviceName: "test", + connection: mockAmqpConnection, + channel: mockChannel, + queueConsumers: &queueConsumers{ + consumers: make(map[string]*queueConsumer), + }, } err := conn.Start(context.Background(), EventStreamConsumer("test", func(ctx context.Context, msg ConsumableEvent[Message]) error { diff --git a/consumer.go b/consumer.go index 2c5e65d..5882f23 100644 --- a/consumer.go +++ b/consumer.go @@ -23,14 +23,12 @@ package goamqp import ( - "context" "errors" "fmt" "time" amqp "github.com/rabbitmq/amqp091-go" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/trace" ) type queueConsumer struct { @@ -39,6 +37,7 @@ type queueConsumer struct { routingKeyToType routingKeyToType notificationCh chan<- Notification errorCh chan<- ErrorNotification + spanNameFn func(info DeliveryInfo) string } func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification, errorCh chan<- ErrorNotification) (<-chan amqp.Delivery, error) { @@ -69,11 +68,8 @@ func (c *queueConsumer) loop(deliveries <-chan amqp.Delivery) { } func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Delivery, deliveryInfo DeliveryInfo) { - tracingCtx := extractToContext(delivery.Headers) - span := trace.SpanFromContext(tracingCtx) - if !span.SpanContext().IsValid() { - tracingCtx, span = otel.Tracer("amqp").Start(context.Background(), fmt.Sprintf("%s#%s", deliveryInfo.Queue, delivery.RoutingKey)) - } + headerCtx := extractToContext(delivery.Headers) + tracingCtx, span := otel.Tracer("amqp").Start(headerCtx, c.spanNameFn(deliveryInfo)) defer span.End() handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.routingKeyToType) startTime := time.Now() @@ -101,10 +97,13 @@ func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Del eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) } -type queueConsumers map[string]*queueConsumer +type queueConsumers struct { + consumers map[string]*queueConsumer + spanNameFn func(info DeliveryInfo) string +} func (c *queueConsumers) get(queueName, routingKey string) (wrappedHandler, bool) { - consumerForQueue, ok := (*c)[queueName] + consumerForQueue, ok := (*c).consumers[queueName] if !ok { return nil, false } @@ -112,13 +111,14 @@ func (c *queueConsumers) get(queueName, routingKey string) (wrappedHandler, bool } func (c *queueConsumers) add(queueName, routingKey string, handler wrappedHandler) error { - consumerForQueue, ok := (*c)[queueName] + consumerForQueue, ok := (*c).consumers[queueName] if !ok { consumerForQueue = &queueConsumer{ - queue: queueName, - handlers: make(routingKeyHandler), + queue: queueName, + handlers: make(routingKeyHandler), + spanNameFn: c.spanNameFn, } - (*c)[queueName] = consumerForQueue + (*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) diff --git a/consumer_test.go b/consumer_test.go index e16f2ea..749fa5b 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -96,6 +96,9 @@ func Test_ConsumerLoop(t *testing.T) { consumer := queueConsumer{ handlers: routingKeyHandler{}, + spanNameFn: func(info DeliveryInfo) string { + return "span" + }, } consumer.handlers.add("key1", handler) consumer.handlers.add("key2", handler) @@ -150,6 +153,9 @@ func Test_HandleDelivery(t *testing.T) { consumer := queueConsumer{ errorCh: errorNotifications, notificationCh: notifications, + spanNameFn: func(info DeliveryInfo) string { + return "span" + }, } deliveryInfo := DeliveryInfo{ RoutingKey: "key", diff --git a/naming.go b/naming.go index 3560859..6993ff6 100644 --- a/naming.go +++ b/naming.go @@ -24,6 +24,7 @@ package goamqp import ( "fmt" + "strings" "github.com/google/uuid" ) @@ -61,3 +62,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/request_response_test.go b/request_response_test.go index 1a48564..4942c7f 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -57,7 +57,7 @@ func Test_RequestResponseHandler(t *testing.T) { 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: nil}, channel.BindingDeclarations[0]) - require.Len(t, *conn.queueConsumers, 1) + require.Len(t, (*conn).queueConsumers.consumers, 1) handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) missing, exists := conn.queueConsumers.get("miggins", "key") diff --git a/setup.go b/setup.go index fe307d6..ea7f373 100644 --- a/setup.go +++ b/setup.go @@ -84,6 +84,19 @@ func WithErrorChannel(errorCh chan<- ErrorNotification) Setup { } } +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 { diff --git a/setup_consumer.go b/setup_consumer.go index d16f594..755fb28 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -45,6 +45,24 @@ type ( RequestResponseEventHandler[T any, R any] func(ctx context.Context, event ConsumableEvent[T]) (R, error) ) +// 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 +// Deprecated: only kept as a convenience for upgrading to new handler functions will be removed in future releases +type HandlerFunc func(msg any, headers Headers) (response any, err error) + +// LegacyHandler provides a way to use old handler functions and type registration +// Deprecated: only provided as a convenience for upgrading to new handler functions will be removed in future releases +func LegacyHandler[T any](handler HandlerFunc, typ T) EventHandler[T] { + return func(ctx context.Context, event ConsumableEvent[T]) error { + _, err := handler(&event.Payload, event.DeliveryInfo.Headers) + if err != nil { + return err + } + return nil + } +} + // TypeMappingHandler wraps a Handler func into an EventHandler in order to use it with the different // Consumer Setup func. // It will use the mappings from WithTypeMapping to determine routing key -> actual event type and pass it to the diff --git a/setup_publisher.go b/setup_publisher.go index 50afdef..23dfaeb 100644 --- a/setup_publisher.go +++ b/setup_publisher.go @@ -50,6 +50,12 @@ 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, msg any, headers ...Header) error { + return p.Publish(ctx, msg, headers...) +} + // Publish tries to publish msg to AMQP. // It requires RoutingKey <-> Type mappings from WithTypeMapping in order to set the correct Routing Key for msg func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) error { From 446c3c925d54fcd0e896d1450ad675ab61b0eb40 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Tue, 26 Nov 2024 11:18:14 +0100 Subject: [PATCH 39/50] chore: cleanup --- connection.go | 7 ------- consumer.go | 4 ++-- consumer_test.go | 38 ++++++++++++++++---------------------- notifications.go | 40 ++++++++++++++++++++++++++-------------- 4 files changed, 44 insertions(+), 45 deletions(-) diff --git a/connection.go b/connection.go index e2972bc..ff8b6e9 100644 --- a/connection.go +++ b/connection.go @@ -24,7 +24,6 @@ package goamqp import ( "context" - "errors" "fmt" "io" "os" @@ -58,12 +57,6 @@ var ( 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 diff --git a/consumer.go b/consumer.go index 5882f23..ff45825 100644 --- a/consumer.go +++ b/consumer.go @@ -77,7 +77,7 @@ func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Del uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} if err := handler(handlerCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerFailed(c.errorCh, deliveryInfo.RoutingKey, elapsed, err) + notifyEventHandlerFailed(c.errorCh, deliveryInfo, elapsed, err) if errors.Is(err, ErrParseJSON) { eventNotParsable(deliveryInfo.Queue, deliveryInfo.RoutingKey) _ = delivery.Nack(false, false) @@ -92,7 +92,7 @@ func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Del } elapsed := time.Since(startTime).Milliseconds() - notifyEventHandlerSucceed(c.notificationCh, deliveryInfo.RoutingKey, elapsed) + notifyEventHandlerSucceed(c.notificationCh, deliveryInfo, elapsed) _ = delivery.Ack(false) eventAck(deliveryInfo.Queue, deliveryInfo.RoutingKey, elapsed) } diff --git a/consumer_test.go b/consumer_test.go index 749fa5b..2faa44e 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -120,30 +120,25 @@ func Test_ConsumerLoop(t *testing.T) { func Test_HandleDelivery(t *testing.T) { tests := []struct { - name string - error error - numberOfAcks int - numberOfNacks int - numberOfRejects int - notification string - errorNotification string + name string + error error + numberOfAcks int + numberOfNacks int + numberOfRejects int }{ { name: "ok", - notification: "event handler for key succeeded", numberOfAcks: 1, }, { - name: "invalid JSON", - error: ErrParseJSON, - errorNotification: "error: failed to parse", - numberOfNacks: 1, + name: "invalid JSON", + error: ErrParseJSON, + numberOfNacks: 1, }, { - name: "no match for routingkey", - error: ErrNoMessageTypeForRouteKey, - errorNotification: "error: no message type for routingkey configured", - numberOfRejects: 1, + name: "no match for routingkey", + error: ErrNoMessageTypeForRouteKey, + numberOfRejects: 1, }, } for _, tt := range tests { @@ -170,13 +165,12 @@ func Test_HandleDelivery(t *testing.T) { } d := delivery(acker, "routingKey", true) consumer.handleDelivery(handler, d, deliveryInfo) - if tt.notification != "" { - notification := <-notifications - require.Contains(t, notification.Message, tt.notification) - } - if tt.errorNotification != "" { + if tt.error != nil { notification := <-errorNotifications - require.ErrorContains(t, notification.Error, tt.errorNotification) + 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) diff --git a/notifications.go b/notifications.go index c392c3c..843a23f 100644 --- a/notifications.go +++ b/notifications.go @@ -22,8 +22,6 @@ package goamqp -import "fmt" - type NotificationSource string const ( @@ -31,28 +29,42 @@ const ( ) type Notification struct { - Message string - Source NotificationSource + DeliveryInfo DeliveryInfo + Duration int64 + Source NotificationSource } type ErrorNotification struct { - Error error - Source NotificationSource + Error error + DeliveryInfo DeliveryInfo + Source NotificationSource + Duration int64 } -func notifyEventHandlerSucceed(ch chan<- Notification, routingKey string, took int64) { +func notifyEventHandlerSucceed(ch chan<- Notification, info DeliveryInfo, took int64) { if ch != nil { - ch <- Notification{ - Message: fmt.Sprintf("event handler for %s succeeded, took %d milliseconds", routingKey, took), - Source: NotificationSourceConsumer, + select { + case ch <- Notification{ + DeliveryInfo: info, + Source: NotificationSourceConsumer, + Duration: took, + }: + default: + // Channel full, or not handling messages } } } -func notifyEventHandlerFailed(ch chan<- ErrorNotification, routingKey string, took int64, err error) { +func notifyEventHandlerFailed(ch chan<- ErrorNotification, info DeliveryInfo, took int64, err error) { if ch != nil { - ch <- ErrorNotification{ - Error: fmt.Errorf("event handler for %s failed, took %d milliseconds, error: %s", routingKey, took, err), - Source: NotificationSourceConsumer, + select { + case ch <- ErrorNotification{ + Error: err, + DeliveryInfo: info, + Source: NotificationSourceConsumer, + Duration: took, + }: + default: + // Channel full, or not handling messages } } } From e4c5f265223906f793d6b29aa7f6a0144eecc76b Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 11 Apr 2025 13:57:52 +0200 Subject: [PATCH 40/50] chore: cleanup after rebase --- Makefile | 2 +- connection.go | 2 +- examples/event-stream/example_test.go | 2 +- go.mod | 1 + go.sum | 23 ++--- internal/handlers/handlers.go | 104 -------------------- internal/handlers/handlers_test.go | 133 ------------------------- internal/handlers/matcher_test.go | 135 -------------------------- message_logger_test.go | 0 setup_consumer_test.go | 4 +- 10 files changed, 13 insertions(+), 393 deletions(-) delete mode 100644 internal/handlers/handlers.go delete mode 100644 internal/handlers/handlers_test.go delete mode 100644 internal/handlers/matcher_test.go delete mode 100644 message_logger_test.go 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/connection.go b/connection.go index ff8b6e9..fe95762 100644 --- a/connection.go +++ b/connection.go @@ -192,7 +192,7 @@ func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { } func queueDeclare(channel AmqpChannel, name string, transient bool) error { - _, err := channel.QueueDeclare(name, !transient, transient, false, false, queueDeclareExpiration) + _, err := channel.QueueDeclare(name, !transient, transient, transient, false, queueDeclareExpiration) return err } diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 91099fb..25c069a 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -33,7 +33,7 @@ import ( var amqpURL = "amqp://user:password@localhost:5672" -func ExampleEventStream() { +func Example_event_stream() { ctx := context.Background() if urlFromEnv := os.Getenv("AMQP_URL"); urlFromEnv != "" { amqpURL = urlFromEnv diff --git a/go.mod b/go.mod index 3870327..0a355d3 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( 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 diff --git a/go.sum b/go.sum index 947883d..7961fdc 100644 --- a/go.sum +++ b/go.sum @@ -2,7 +2,7 @@ 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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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= @@ -16,12 +16,10 @@ 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +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= @@ -34,16 +32,12 @@ github.com/prometheus/common v0.45.0 h1:2BGz0eBc2hdMDLnO/8n0jeB3oPrt2D08CekT0lne 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.9.0 h1:qrQtyzB4H8BQgEuJwhmVQqVHB9O4+MNDJCCAcpc3Aoo= -github.com/rabbitmq/amqp091-go v1.9.0/go.mod h1:+jPrT9iY2eLjRaMSRHUhc3z14E/l85kv/f+6luSD3pc= +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/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= -github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +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= @@ -52,7 +46,6 @@ go.opentelemetry.io/otel/sdk v1.22.0 h1:6coWHw9xw7EfClIC/+O31R8IY3/+EiRFHevmHafB 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.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= @@ -62,9 +55,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 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-20180628173108-788fd7840127/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.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/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_test.go b/message_logger_test.go deleted file mode 100644 index e69de29..0000000 diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 01c19c0..06c3572 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -106,7 +106,7 @@ func Test_Consumer_Setups(t *testing.T) { })}, 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: nil}}, - expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, + expectedQueues: []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}}}, expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, }, { @@ -120,7 +120,7 @@ func Test_Consumer_Setups(t *testing.T) { }, 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: nil}}, - expectedQueues: []QueueDeclaration{{name: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", durable: false, autoDelete: true, noWait: false, args: amqp.Table{"x-expires": 432000000}}}, + expectedQueues: []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}}}, expectedError: "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix", }, } From fa2007667da560e34d8c578974c7fa95ef87d908 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 11 Apr 2025 16:30:05 +0200 Subject: [PATCH 41/50] feat: update queue declaration and parameters Update the queue declaration in tests to include the `exclusive` parameter and align the expected arguments with the current configuration using `defaultQueueOptions`. Change the queue type from "classic" to "quorum" for improved message handling. Refactor the `ServiceRequestConsumer` function to accept additional options for flexibility in queue setup. Remove redundant test code to streamline maintenance. --- connection.go | 25 ++++++++++++++----------- connection_test.go | 27 +++++++++++++++++---------- integration/integration_test.go | 25 +++++++++++++------------ queue_binding_config.go | 23 ++++++++++++++++------- request_response_test.go | 3 +-- setup.go | 2 +- setup_consumer.go | 28 +++++++++++++++++++--------- setup_consumer_test.go | 25 +++++++++++++++++-------- 8 files changed, 98 insertions(+), 60 deletions(-) diff --git a/connection.go b/connection.go index fe95762..02852da 100644 --- a/connection.go +++ b/connection.go @@ -88,11 +88,9 @@ func (c *Connection) Start(ctx context.Context, opts ...Setup) error { 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) @@ -181,18 +179,18 @@ func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) if err := exchangeDeclare(c.channel, cfg.exchangeName, cfg.kind); err != nil { return err } - if err := queueDeclare(c.channel, cfg.queueName, cfg.transient); err != nil { + if err := queueDeclare(c.channel, cfg); 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) } func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) } -func queueDeclare(channel AmqpChannel, name string, transient bool) error { - _, err := channel.QueueDeclare(name, !transient, transient, transient, false, queueDeclareExpiration) +func queueDeclare(channel AmqpChannel, cfg *QueueBindingConfig) error { + _, err := channel.QueueDeclare(cfg.queueName, true, false, false, false, cfg.queueHeaders) return err } @@ -205,15 +203,20 @@ const ( ) const ( - headerService = "service" - headerExpires = "x-expires" + headerService = "service" + headerExpires = "x-expires" + headerQueueType = "x-queue-type" + headerSingleActiveConsumer = "x-single-active-consumer" ) 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{ + headerQueueType: "quorum", + headerSingleActiveConsumer: true, + headerExpires: int(deleteQueueAfter.Seconds() * 1000)} ) func newConnection(serviceName string, uri amqp.URI) *Connection { diff --git a/connection_test.go b/connection_test.go index 2423235..04cb1db 100644 --- a/connection_test.go +++ b/connection_test.go @@ -102,12 +102,13 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { } conn := mockConnection(channel) cfg := &QueueBindingConfig{ - routingKey: "routingkey", - handler: nil, - queueName: "queue", - exchangeName: "exchange", - kind: kindDirect, - headers: nil, + routingKey: "routingkey", + handler: nil, + queueName: "queue", + exchangeName: "exchange", + kind: kindDirect, + queueBindingHeaders: nil, + queueHeaders: nil, } err := conn.messageHandlerBindQueueToExchange(cfg) if tt.queueDeclarationError != nil { @@ -246,19 +247,25 @@ func Test_AmqpConfig(t *testing.T) { func Test_QueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, "test", false) + err := queueDeclare(channel, &QueueBindingConfig{ + 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 := queueDeclare(channel, "test", true) + err := queueDeclare(channel, &QueueBindingConfig{ + 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) { diff --git a/integration/integration_test.go b/integration/integration_test.go index f11a9c4..1d7c022 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -102,7 +102,7 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { require.Equal(suite.T(), []Queue{{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -188,7 +188,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -214,7 +214,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -294,7 +294,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -323,7 +323,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -413,7 +413,7 @@ func (suite *IntegrationTestSuite) Test_EventStream() { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, @@ -434,11 +434,11 @@ func (suite *IntegrationTestSuite) Test_EventStream() { } else { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XExpires: 1, + XQueueType: "quorum", }, - AutoDelete: true, - Durable: false, + AutoDelete: false, + Durable: true, Exclusive: false, ExclusiveConsumerTag: nil, Name: q.Name, @@ -461,7 +461,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) @@ -541,7 +542,7 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "classic", + XQueueType: "quorum", }, AutoDelete: false, Durable: true, diff --git a/queue_binding_config.go b/queue_binding_config.go index 3cc329c..f9b1402 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -36,13 +36,13 @@ type QueueBindingConfigSetup func(config *QueueBindingConfig) error // QueueBindingConfig is a wrapper around the actual amqp queue configuration type QueueBindingConfig struct { - routingKey string - handler wrappedHandler - queueName string - exchangeName string - kind kind - headers amqp.Table - transient bool + routingKey string + handler wrappedHandler + queueName string + exchangeName string + kind kind + queueHeaders amqp.Table + queueBindingHeaders amqp.Table } // AddQueueNameSuffix appends the provided suffix to the queue name @@ -57,6 +57,15 @@ func AddQueueNameSuffix(suffix string) QueueBindingConfigSetup { } } +// 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() QueueBindingConfigSetup { + return func(config *QueueBindingConfig) error { + config.queueHeaders[headerSingleActiveConsumer] = false + return nil + } +} + // getQueueBindingConfigSetupFuncName returns the name of the QueueBindingConfigSetup function func getQueueBindingConfigSetupFuncName(f QueueBindingConfigSetup) string { return runtime.FuncForPC(reflect.ValueOf(f).Pointer()).Name() diff --git a/request_response_test.go b/request_response_test.go index 4942c7f..c6fec3e 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -29,7 +29,6 @@ import ( "runtime" "testing" - amqp "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" ) @@ -52,7 +51,7 @@ func Test_RequestResponseHandler(t *testing.T) { 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, args: amqp.Table{"x-expires": 432000000}}, channel.QueueDeclarations[0]) + 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: nil}, channel.BindingDeclarations[0]) diff --git a/setup.go b/setup.go index ea7f373..f065542 100644 --- a/setup.go +++ b/setup.go @@ -63,7 +63,7 @@ func WithTypeMapping(routingKey string, msgType any) Setup { // 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) + return conn.channel.Qos(limit, 0, false) } } diff --git a/setup_consumer.go b/setup_consumer.go index 755fb28..318a2d0 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -26,6 +26,7 @@ import ( "context" "encoding/json" "fmt" + "maps" "reflect" amqp "github.com/rabbitmq/amqp091-go" @@ -96,12 +97,13 @@ func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T]) Setup { return func(c *Connection) error { config := &QueueBindingConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceResponseQueueName(targetService, c.serviceName), - exchangeName: serviceResponseExchangeName(targetService), - kind: kindHeaders, - headers: amqp.Table{headerService: c.serviceName}, + routingKey: routingKey, + handler: newWrappedHandler(handler), + queueName: serviceResponseQueueName(targetService, c.serviceName), + exchangeName: serviceResponseExchangeName(targetService), + kind: kindHeaders, + queueBindingHeaders: amqp.Table{headerService: c.serviceName}, + queueHeaders: maps.Clone(defaultQueueOptions), } return c.messageHandlerBindQueueToExchange(config) @@ -110,7 +112,7 @@ func ServiceResponseConsumer[T any](targetService, routingKey string, handler Ev // 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]) Setup { +func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], opts ...QueueBindingConfigSetup) Setup { return func(c *Connection) error { resExchangeName := serviceResponseExchangeName(c.serviceName) if err := exchangeDeclare(c.channel, resExchangeName, kindHeaders); err != nil { @@ -123,8 +125,13 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T]) S queueName: serviceRequestQueueName(c.serviceName), exchangeName: serviceRequestExchangeName(c.serviceName), kind: kindDirect, + queueHeaders: maps.Clone(defaultQueueOptions), + } + 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) } } @@ -139,6 +146,7 @@ func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], queueName: serviceEventQueueName(exchangeName, c.serviceName), exchangeName: exchangeName, kind: kindTopic, + queueHeaders: maps.Clone(defaultQueueOptions), } for _, f := range opts { if err := f(config); err != nil { @@ -165,13 +173,15 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa return func(c *Connection) error { queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) + headers := maps.Clone(defaultQueueOptions) + headers[headerExpires] = 1 config := &QueueBindingConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: queueName, exchangeName: exchangeName, kind: kindTopic, - transient: true, + queueHeaders: headers, } return c.messageHandlerBindQueueToExchange(config) } diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 06c3572..96a102b 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -27,6 +27,7 @@ import ( "encoding/json" "errors" "fmt" + "maps" "reflect" "testing" @@ -55,7 +56,7 @@ func Test_Consumer_Setups(t *testing.T) { 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, args: amqp.Table{"x-expires": 432000000}}}, + 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: nil}}, expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, }, @@ -65,7 +66,7 @@ func Test_Consumer_Setups(t *testing.T) { 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, args: amqp.Table{"x-expires": 432000000}}}, + 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: nil}}, expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-suffix", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, }, @@ -85,7 +86,7 @@ func Test_Consumer_Setups(t *testing.T) { {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, args: amqp.Table{"x-expires": 432000000}}}, + 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: nil}}, expectedConsumer: []Consumer{{queue: "svc.direct.exchange.request.queue", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, }, @@ -95,7 +96,7 @@ func Test_Consumer_Setups(t *testing.T) { 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, args: amqp.Table{"x-expires": 432000000}}}, + 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}}, }, @@ -106,8 +107,12 @@ func Test_Consumer_Setups(t *testing.T) { })}, 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: nil}}, - expectedQueues: []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}}}, - expectedConsumer: []Consumer{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", consumer: "", noWait: false, noLocal: false, exclusive: false, autoAck: false, args: nil}}, + 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[headerExpires] = 1 + 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", @@ -120,8 +125,12 @@ func Test_Consumer_Setups(t *testing.T) { }, 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: nil}}, - expectedQueues: []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}}}, - expectedError: "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix", + 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[headerExpires] = 1 + 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 { From 344dc6872d20481aeec54edc7a8bbc786a5459bb Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Fri, 11 Apr 2025 18:36:45 +0200 Subject: [PATCH 42/50] refactor: rename QueueBindingConfig to ConsumerConfig Update the messaging configuration structure from QueueBindingConfig to ConsumerConfig to improve clarity and better represent the usage of the configuration in the context of consumer setup. This change includes updates to related methods and tests to ensure consistent naming and functionality throughout the codebase. --- connection.go | 4 ++-- connection_test.go | 6 +++--- queue_binding_config.go | 20 ++++++++++---------- queue_binding_config_test.go | 4 ++-- setup_consumer.go | 14 +++++++------- 5 files changed, 24 insertions(+), 24 deletions(-) diff --git a/connection.go b/connection.go index 02852da..df793db 100644 --- a/connection.go +++ b/connection.go @@ -171,7 +171,7 @@ func (c *Connection) connectToAmqpURL() error { return nil } -func (c *Connection) messageHandlerBindQueueToExchange(cfg *QueueBindingConfig) error { +func (c *Connection) messageHandlerBindQueueToExchange(cfg *ConsumerConfig) error { if err := c.queueConsumers.add(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err } @@ -189,7 +189,7 @@ func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) } -func queueDeclare(channel AmqpChannel, cfg *QueueBindingConfig) error { +func queueDeclare(channel AmqpChannel, cfg *ConsumerConfig) error { _, err := channel.QueueDeclare(cfg.queueName, true, false, false, false, cfg.queueHeaders) return err } diff --git a/connection_test.go b/connection_test.go index 04cb1db..cc3ba18 100644 --- a/connection_test.go +++ b/connection_test.go @@ -101,7 +101,7 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { ExchangeDeclarationError: &tt.exchangeDeclarationError, } conn := mockConnection(channel) - cfg := &QueueBindingConfig{ + cfg := &ConsumerConfig{ routingKey: "routingkey", handler: nil, queueName: "queue", @@ -247,7 +247,7 @@ func Test_AmqpConfig(t *testing.T) { func Test_QueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, &QueueBindingConfig{ + err := queueDeclare(channel, &ConsumerConfig{ queueName: "test", exchangeName: "test", queueHeaders: defaultQueueOptions}) @@ -258,7 +258,7 @@ func Test_QueueDeclare(t *testing.T) { func Test_TransientQueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, &QueueBindingConfig{ + err := queueDeclare(channel, &ConsumerConfig{ queueName: "test", exchangeName: "test", queueHeaders: defaultQueueOptions}) diff --git a/queue_binding_config.go b/queue_binding_config.go index f9b1402..32ee2e5 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -30,12 +30,12 @@ import ( amqp "github.com/rabbitmq/amqp091-go" ) -// QueueBindingConfigSetup is a setup function that takes a QueueBindingConfig and provide custom changes to the +// ConsumerOptions is a setup function that takes a ConsumerConfig and provide custom changes to the // configuration -type QueueBindingConfigSetup func(config *QueueBindingConfig) error +type ConsumerOptions func(config *ConsumerConfig) error -// QueueBindingConfig is a wrapper around the actual amqp queue configuration -type QueueBindingConfig struct { +// ConsumerConfig is a wrapper around the actual amqp queue configuration +type ConsumerConfig struct { routingKey string handler wrappedHandler queueName string @@ -47,8 +47,8 @@ type QueueBindingConfig struct { // 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) QueueBindingConfigSetup { - return func(config *QueueBindingConfig) error { +func AddQueueNameSuffix(suffix string) ConsumerOptions { + return func(config *ConsumerConfig) error { if suffix == "" { return ErrEmptySuffix } @@ -59,14 +59,14 @@ func AddQueueNameSuffix(suffix string) QueueBindingConfigSetup { // 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() QueueBindingConfigSetup { - return func(config *QueueBindingConfig) error { +func DisableSingleActiveConsumer() ConsumerOptions { + return func(config *ConsumerConfig) error { config.queueHeaders[headerSingleActiveConsumer] = false return nil } } -// getQueueBindingConfigSetupFuncName returns the name of the QueueBindingConfigSetup function -func getQueueBindingConfigSetupFuncName(f QueueBindingConfigSetup) string { +// 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 index 3383de9..6762fe7 100644 --- a/queue_binding_config_test.go +++ b/queue_binding_config_test.go @@ -29,11 +29,11 @@ import ( ) func TestEmptyQueueNameSuffix(t *testing.T) { - require.EqualError(t, AddQueueNameSuffix("")(&QueueBindingConfig{}), ErrEmptySuffix.Error()) + require.EqualError(t, AddQueueNameSuffix("")(&ConsumerConfig{}), ErrEmptySuffix.Error()) } func TestQueueNameSuffix(t *testing.T) { - cfg := &QueueBindingConfig{queueName: "queue"} + cfg := &ConsumerConfig{queueName: "queue"} require.NoError(t, AddQueueNameSuffix("suffix")(cfg)) require.Equal(t, "queue-suffix", cfg.queueName) } diff --git a/setup_consumer.go b/setup_consumer.go index 318a2d0..aa9c931 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -88,7 +88,7 @@ func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { // 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 ...QueueBindingConfigSetup) Setup { +func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { return StreamConsumer(defaultEventExchangeName, routingKey, handler, opts...) } @@ -96,7 +96,7 @@ func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], opts // It sets up ap a durable, persistent consumer (exchange->queue) for responses from targetService func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T]) Setup { return func(c *Connection) error { - config := &QueueBindingConfig{ + config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: serviceResponseQueueName(targetService, c.serviceName), @@ -112,14 +112,14 @@ func ServiceResponseConsumer[T any](targetService, routingKey string, handler Ev // 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 ...QueueBindingConfigSetup) Setup { +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, kindHeaders); err != nil { return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) } - config := &QueueBindingConfig{ + config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: serviceRequestQueueName(c.serviceName), @@ -137,10 +137,10 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], o } // StreamConsumer sets up ap a durable, persistent event stream consumer. -func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...QueueBindingConfigSetup) Setup { +func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { exchangeName := topicExchangeName(exchange) return func(c *Connection) error { - config := &QueueBindingConfig{ + config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: serviceEventQueueName(exchangeName, c.serviceName), @@ -175,7 +175,7 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) headers := maps.Clone(defaultQueueOptions) headers[headerExpires] = 1 - config := &QueueBindingConfig{ + config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: queueName, From b5b2944ee3813254d01b9512c98dfc5e3625b7df Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 16 Apr 2025 15:00:34 +0200 Subject: [PATCH 43/50] fix: update queue and exchange types to use constants Change queue type assignments from string literals to constants from the AMQP library for improved consistency and clarity. Update related test cases to reflect the new constant usage. Introduce QoS configuration to enhance consumer performance and round-robin behavior. --- connection.go | 21 +++++---------------- connection_test.go | 2 +- integration/integration_test.go | 17 +++++++++-------- naming.go | 3 ++- queue_binding_config.go | 4 ++-- setup_consumer.go | 12 ++++++------ setup_consumer_test.go | 4 ++-- setup_publisher.go | 4 ++-- setup_publisher_test.go | 8 ++++---- 9 files changed, 33 insertions(+), 42 deletions(-) diff --git a/connection.go b/connection.go index df793db..ca0729f 100644 --- a/connection.go +++ b/connection.go @@ -185,7 +185,7 @@ func (c *Connection) messageHandlerBindQueueToExchange(cfg *ConsumerConfig) erro return c.channel.QueueBind(cfg.queueName, cfg.routingKey, cfg.exchangeName, false, cfg.queueBindingHeaders) } -func exchangeDeclare(channel AmqpChannel, name string, kind kind) error { +func exchangeDeclare(channel AmqpChannel, name string, kind string) error { return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) } @@ -194,19 +194,8 @@ func queueDeclare(channel AmqpChannel, cfg *ConsumerConfig) error { return err } -type kind string - -const ( - kindDirect = "direct" - kindHeaders = "headers" - kindTopic = "topic" -) - const ( - headerService = "service" - headerExpires = "x-expires" - headerQueueType = "x-queue-type" - headerSingleActiveConsumer = "x-single-active-consumer" + headerService = "service" ) const contentType = "application/json" @@ -214,9 +203,9 @@ const contentType = "application/json" var ( deleteQueueAfter = 5 * 24 * time.Hour defaultQueueOptions = amqp.Table{ - headerQueueType: "quorum", - headerSingleActiveConsumer: true, - headerExpires: int(deleteQueueAfter.Seconds() * 1000)} + amqp.QueueTypeArg: amqp.QueueTypeQuorum, + amqp.SingleActiveConsumerArg: true, + amqp.QueueTTLArg: int(deleteQueueAfter.Seconds() * 1000)} ) func newConnection(serviceName string, uri amqp.URI) *Connection { diff --git a/connection_test.go b/connection_test.go index cc3ba18..9205c40 100644 --- a/connection_test.go +++ b/connection_test.go @@ -106,7 +106,7 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { handler: nil, queueName: "queue", exchangeName: "exchange", - kind: kindDirect, + kind: amqp.ExchangeDirect, queueBindingHeaders: nil, queueHeaders: nil, } diff --git a/integration/integration_test.go b/integration/integration_test.go index 1d7c022..65e7fcb 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -34,6 +34,7 @@ import ( "testing" "time" + amqp "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "go.uber.org/goleak" @@ -102,7 +103,7 @@ func (suite *IntegrationTestSuite) Test_ServiceRequestConsumer() { require.Equal(suite.T(), []Queue{{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -188,7 +189,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -214,7 +215,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -294,7 +295,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -323,7 +324,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { require.Equal(suite.T(), &Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -413,7 +414,7 @@ func (suite *IntegrationTestSuite) Test_EventStream() { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -435,7 +436,7 @@ func (suite *IntegrationTestSuite) Test_EventStream() { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ XExpires: 1, - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, @@ -542,7 +543,7 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ XExpires: int(5 * 24 * time.Hour.Milliseconds()), - XQueueType: "quorum", + XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, Durable: true, diff --git a/naming.go b/naming.go index 6993ff6..bfa51ba 100644 --- a/naming.go +++ b/naming.go @@ -27,12 +27,13 @@ import ( "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 { diff --git a/queue_binding_config.go b/queue_binding_config.go index 32ee2e5..a36491a 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -40,7 +40,7 @@ type ConsumerConfig struct { handler wrappedHandler queueName string exchangeName string - kind kind + kind string queueHeaders amqp.Table queueBindingHeaders amqp.Table } @@ -61,7 +61,7 @@ func AddQueueNameSuffix(suffix string) ConsumerOptions { // https://www.rabbitmq.com/docs/consumers#exclusivity func DisableSingleActiveConsumer() ConsumerOptions { return func(config *ConsumerConfig) error { - config.queueHeaders[headerSingleActiveConsumer] = false + config.queueHeaders[amqp.SingleActiveConsumerArg] = false return nil } } diff --git a/setup_consumer.go b/setup_consumer.go index aa9c931..6500106 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -101,7 +101,7 @@ func ServiceResponseConsumer[T any](targetService, routingKey string, handler Ev handler: newWrappedHandler(handler), queueName: serviceResponseQueueName(targetService, c.serviceName), exchangeName: serviceResponseExchangeName(targetService), - kind: kindHeaders, + kind: amqp.ExchangeHeaders, queueBindingHeaders: amqp.Table{headerService: c.serviceName}, queueHeaders: maps.Clone(defaultQueueOptions), } @@ -115,7 +115,7 @@ func ServiceResponseConsumer[T any](targetService, routingKey string, handler Ev 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, kindHeaders); err != nil { + if err := exchangeDeclare(c.channel, resExchangeName, amqp.ExchangeHeaders); err != nil { return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) } @@ -124,7 +124,7 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], o handler: newWrappedHandler(handler), queueName: serviceRequestQueueName(c.serviceName), exchangeName: serviceRequestExchangeName(c.serviceName), - kind: kindDirect, + kind: amqp.ExchangeDirect, queueHeaders: maps.Clone(defaultQueueOptions), } for _, f := range opts { @@ -145,7 +145,7 @@ func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], handler: newWrappedHandler(handler), queueName: serviceEventQueueName(exchangeName, c.serviceName), exchangeName: exchangeName, - kind: kindTopic, + kind: amqp.ExchangeTopic, queueHeaders: maps.Clone(defaultQueueOptions), } for _, f := range opts { @@ -174,13 +174,13 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa return func(c *Connection) error { queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) headers := maps.Clone(defaultQueueOptions) - headers[headerExpires] = 1 + headers[amqp.QueueTTLArg] = 1 config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), queueName: queueName, exchangeName: exchangeName, - kind: kindTopic, + kind: amqp.ExchangeTopic, queueHeaders: headers, } return c.messageHandlerBindQueueToExchange(config) diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 96a102b..bcbea65 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -109,7 +109,7 @@ func Test_Consumer_Setups(t *testing.T) { expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: nil}}, 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[headerExpires] = 1 + clone[amqp.QueueTTLArg] = 1 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}}, @@ -127,7 +127,7 @@ func Test_Consumer_Setups(t *testing.T) { expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "root.key", exchange: "events.topic.exchange", noWait: false, args: nil}}, 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[headerExpires] = 1 + clone[amqp.QueueTTLArg] = 1 return clone }()}}, expectedError: "routingkey root.# overlaps root.key for queue events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f, consider using AddQueueNameSuffix", diff --git a/setup_publisher.go b/setup_publisher.go index 23dfaeb..fd17603 100644 --- a/setup_publisher.go +++ b/setup_publisher.go @@ -90,7 +90,7 @@ func EventStreamPublisher(publisher *Publisher) Setup { func StreamPublisher(exchange string, publisher *Publisher) Setup { exchangeName := topicExchangeName(exchange) return func(c *Connection) error { - if err := exchangeDeclare(c.channel, exchangeName, kindTopic); err != nil { + 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, c.typeToKey) @@ -110,7 +110,7 @@ func QueuePublisher(publisher *Publisher, destinationQueueName string) Setup { func ServicePublisher(targetService string, publisher *Publisher) Setup { exchangeName := serviceRequestExchangeName(targetService) return func(c *Connection) error { - if err := exchangeDeclare(c.channel, exchangeName, kindDirect); err != nil { + if err := exchangeDeclare(c.channel, exchangeName, amqp.ExchangeDirect); err != nil { return err } return publisher.setup(c.channel, c.serviceName, exchangeName, c.typeToKey) diff --git a/setup_publisher_test.go b/setup_publisher_test.go index 0807983..910d9c3 100644 --- a/setup_publisher_test.go +++ b/setup_publisher_test.go @@ -74,7 +74,7 @@ func Test_Publisher_Setups(t *testing.T) { }, messages: []any{TestMessage{"test", true}}, headers: []Header{{"x-header", "header"}}, - expectedExchanges: []ExchangeDeclaration{{name: topicExchangeName(defaultEventExchangeName), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindTopic, args: nil}}, + 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", @@ -93,7 +93,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p), WithTypeMapping("key", TestMessage{})} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, messages: []any{TestMessage{"test", true}}, expectedPublished: []*Publish{{ exchange: serviceRequestExchangeName("svc"), @@ -113,7 +113,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p), WithTypeMapping("key1", TestMessage{}), WithTypeMapping("key2", TestMessage2{})} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, messages: []any{ TestMessage{"test", true}, TestMessage2{"test", false}, @@ -161,7 +161,7 @@ func Test_Publisher_Setups(t *testing.T) { opts: func(p *Publisher) []Setup { return []Setup{ServicePublisher("svc", p)} }, - expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: kindDirect, args: nil}}, + expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, messages: []any{ TestMessage{"test", true}, }, From 56c522a37d9f2586c6e097459e9aae6dbc764ce2 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 16 Apr 2025 15:02:12 +0200 Subject: [PATCH 44/50] chore: update Go version in go.mod to 1.23 This commit updates the Go version specified in the go.mod file from 1.22.12 to 1.23. This change ensures compatibility with the latest features and improvements in the Go 1.23 release. --- go.mod | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/go.mod b/go.mod index 0a355d3..12564b8 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/sparetimecoders/goamqp -go 1.22.12 +go 1.23 require ( github.com/google/uuid v1.6.0 From 37c345f449129154c2690b5a0c4ca737e7a6cc29 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 16 Apr 2025 15:02:54 +0200 Subject: [PATCH 45/50] feat(mock): add Close method to MockAmqpChannel Implements a Close method for the MockAmqpChannel struct to satisfy the io.Closer interface. This allows for better integration with code that requires closing channels gracefully in tests. Additionally, it updates the AmqpChannel interface to include the io.Closer interface. --- channel.go | 2 ++ mocks_test.go | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/channel.go b/channel.go index 5392072..59f23a0 100644 --- a/channel.go +++ b/channel.go @@ -24,12 +24,14 @@ 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 diff --git a/mocks_test.go b/mocks_test.go index f6827c2..44ee000 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -137,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 From e0fa3660df223af30cdbb19fa15dcdeee07c0fde Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 16 Apr 2025 15:54:21 +0200 Subject: [PATCH 46/50] fix: increase message TTL values in tests and consumer setup Update the Time-To-Live (TTL) for message queues from 1 to 1000 across various test cases and the consumer setup. This change ensures that messages have a longer lifespan, allowing for better testing conditions and more reliability in message delivery during integration scenarios. Adjustments are made in the consumer setup and corresponding test files to align with this new TTL requirement. --- integration/integration_test.go | 2 +- setup_consumer.go | 2 +- setup_consumer_test.go | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/integration_test.go b/integration/integration_test.go index 65e7fcb..d4d9785 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -435,7 +435,7 @@ func (suite *IntegrationTestSuite) Test_EventStream() { } else { require.Equal(suite.T(), Queue{ Arguments: QueueArguments{ - XExpires: 1, + XExpires: 1000, XQueueType: amqp.QueueTypeQuorum, }, AutoDelete: false, diff --git a/setup_consumer.go b/setup_consumer.go index 6500106..7f1769e 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -174,7 +174,7 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa return func(c *Connection) error { queueName := serviceEventRandomQueueName(exchangeName, c.serviceName) headers := maps.Clone(defaultQueueOptions) - headers[amqp.QueueTTLArg] = 1 + headers[amqp.QueueTTLArg] = 1000 config := &ConsumerConfig{ routingKey: routingKey, handler: newWrappedHandler(handler), diff --git a/setup_consumer_test.go b/setup_consumer_test.go index bcbea65..ecc6754 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -109,7 +109,7 @@ func Test_Consumer_Setups(t *testing.T) { expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "key", exchange: "events.topic.exchange", noWait: false, args: nil}}, 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] = 1 + 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}}, @@ -127,7 +127,7 @@ func Test_Consumer_Setups(t *testing.T) { expectedBindings: []BindingDeclaration{{queue: "events.topic.exchange.queue.svc-00010203-0405-4607-8809-0a0b0c0d0e0f", key: "root.key", exchange: "events.topic.exchange", noWait: false, args: nil}}, 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] = 1 + 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", From 3215cf7c34495dbe5e95e3a8e3cd0a77e6293f6b Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 16 Apr 2025 17:01:25 +0200 Subject: [PATCH 47/50] refactor: standardize consumerConfig struct usage Refactor the ConsumerConfig to consumerConfig for consistency across the codebase. Update tests and functions to utilize the new struct definition while ensuring the functionality remains intact. This change improves code clarity and aligns with naming conventions, --- connection.go | 4 +- connection_test.go | 6 +-- queue_binding_config.go | 32 ++++++++++--- queue_binding_config_test.go | 4 +- request_response_test.go | 3 +- setup_consumer.go | 88 +++++++++++++++++++----------------- setup_consumer_test.go | 10 ++-- 7 files changed, 86 insertions(+), 61 deletions(-) diff --git a/connection.go b/connection.go index ca0729f..4400fb1 100644 --- a/connection.go +++ b/connection.go @@ -171,7 +171,7 @@ func (c *Connection) connectToAmqpURL() error { return nil } -func (c *Connection) messageHandlerBindQueueToExchange(cfg *ConsumerConfig) error { +func (c *Connection) messageHandlerBindQueueToExchange(cfg *consumerConfig) error { if err := c.queueConsumers.add(cfg.queueName, cfg.routingKey, cfg.handler); err != nil { return err } @@ -189,7 +189,7 @@ func exchangeDeclare(channel AmqpChannel, name string, kind string) error { return channel.ExchangeDeclare(name, string(kind), true, false, false, false, nil) } -func queueDeclare(channel AmqpChannel, cfg *ConsumerConfig) error { +func queueDeclare(channel AmqpChannel, cfg *consumerConfig) error { _, err := channel.QueueDeclare(cfg.queueName, true, false, false, false, cfg.queueHeaders) return err } diff --git a/connection_test.go b/connection_test.go index 9205c40..5726cf4 100644 --- a/connection_test.go +++ b/connection_test.go @@ -101,7 +101,7 @@ func Test_messageHandlerBindQueueToExchange(t *testing.T) { ExchangeDeclarationError: &tt.exchangeDeclarationError, } conn := mockConnection(channel) - cfg := &ConsumerConfig{ + cfg := &consumerConfig{ routingKey: "routingkey", handler: nil, queueName: "queue", @@ -247,7 +247,7 @@ func Test_AmqpConfig(t *testing.T) { func Test_QueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, &ConsumerConfig{ + err := queueDeclare(channel, &consumerConfig{ queueName: "test", exchangeName: "test", queueHeaders: defaultQueueOptions}) @@ -258,7 +258,7 @@ func Test_QueueDeclare(t *testing.T) { func Test_TransientQueueDeclare(t *testing.T) { channel := NewMockAmqpChannel() - err := queueDeclare(channel, &ConsumerConfig{ + err := queueDeclare(channel, &consumerConfig{ queueName: "test", exchangeName: "test", queueHeaders: defaultQueueOptions}) diff --git a/queue_binding_config.go b/queue_binding_config.go index a36491a..14eeedc 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -24,18 +24,38 @@ 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 +// ConsumerOptions is a setup function that takes a consumerConfig and provide custom changes to the // configuration -type ConsumerOptions func(config *ConsumerConfig) error +type ConsumerOptions func(config *consumerConfig) error -// ConsumerConfig is a wrapper around the actual amqp queue configuration -type ConsumerConfig struct { +// 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 @@ -48,7 +68,7 @@ type ConsumerConfig struct { // 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 { + return func(config *consumerConfig) error { if suffix == "" { return ErrEmptySuffix } @@ -60,7 +80,7 @@ func AddQueueNameSuffix(suffix string) ConsumerOptions { // 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 { + return func(config *consumerConfig) error { config.queueHeaders[amqp.SingleActiveConsumerArg] = false return nil } diff --git a/queue_binding_config_test.go b/queue_binding_config_test.go index 6762fe7..2ac9173 100644 --- a/queue_binding_config_test.go +++ b/queue_binding_config_test.go @@ -29,11 +29,11 @@ import ( ) func TestEmptyQueueNameSuffix(t *testing.T) { - require.EqualError(t, AddQueueNameSuffix("")(&ConsumerConfig{}), ErrEmptySuffix.Error()) + require.EqualError(t, AddQueueNameSuffix("")(&consumerConfig{}), ErrEmptySuffix.Error()) } func TestQueueNameSuffix(t *testing.T) { - cfg := &ConsumerConfig{queueName: "queue"} + cfg := &consumerConfig{queueName: "queue"} require.NoError(t, AddQueueNameSuffix("suffix")(cfg)) require.Equal(t, "queue-suffix", cfg.queueName) } diff --git a/request_response_test.go b/request_response_test.go index c6fec3e..c1dd163 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -29,6 +29,7 @@ import ( "runtime" "testing" + "github.com/rabbitmq/amqp091-go" "github.com/stretchr/testify/require" ) @@ -54,7 +55,7 @@ func Test_RequestResponseHandler(t *testing.T) { 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: nil}, channel.BindingDeclarations[0]) + 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") diff --git a/setup_consumer.go b/setup_consumer.go index 7f1769e..015e587 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -26,7 +26,6 @@ import ( "context" "encoding/json" "fmt" - "maps" "reflect" amqp "github.com/rabbitmq/amqp091-go" @@ -94,16 +93,21 @@ func EventStreamConsumer[T any](routingKey string, handler EventHandler[T], 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]) Setup { +func ServiceResponseConsumer[T any](targetService, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { return func(c *Connection) error { - config := &ConsumerConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceResponseQueueName(targetService, c.serviceName), - exchangeName: serviceResponseExchangeName(targetService), - kind: amqp.ExchangeHeaders, - queueBindingHeaders: amqp.Table{headerService: c.serviceName}, - queueHeaders: maps.Clone(defaultQueueOptions), + 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) @@ -119,13 +123,14 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], o return fmt.Errorf("failed to create exchange %s, %w", resExchangeName, err) } - config := &ConsumerConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceRequestQueueName(c.serviceName), - exchangeName: serviceRequestExchangeName(c.serviceName), - kind: amqp.ExchangeDirect, - queueHeaders: maps.Clone(defaultQueueOptions), + 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 { @@ -140,18 +145,14 @@ func ServiceRequestConsumer[T any](routingKey string, handler EventHandler[T], o func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], opts ...ConsumerOptions) Setup { exchangeName := topicExchangeName(exchange) return func(c *Connection) error { - config := &ConsumerConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: serviceEventQueueName(exchangeName, c.serviceName), - exchangeName: exchangeName, - kind: amqp.ExchangeTopic, - queueHeaders: maps.Clone(defaultQueueOptions), - } - for _, f := range opts { - if err := f(config); err != nil { - return fmt.Errorf("queuebinding setup function <%s> failed, %v", getQueueBindingConfigSetupFuncName(f), err) - } + config, err := newConsumerConfig(routingKey, + exchangeName, + serviceEventQueueName(exchangeName, c.serviceName), + amqp.ExchangeTopic, + newWrappedHandler(handler), + opts...) + if err != nil { + return err } return c.messageHandlerBindQueueToExchange(config) @@ -161,28 +162,31 @@ func StreamConsumer[T any](exchange, routingKey string, handler EventHandler[T], // 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]) Setup { - return TransientStreamConsumer(defaultEventExchangeName, routingKey, handler) +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]) Setup { +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) - headers := maps.Clone(defaultQueueOptions) - headers[amqp.QueueTTLArg] = 1000 - config := &ConsumerConfig{ - routingKey: routingKey, - handler: newWrappedHandler(handler), - queueName: queueName, - exchangeName: exchangeName, - kind: amqp.ExchangeTopic, - queueHeaders: headers, + 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 index ecc6754..62953b3 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -57,7 +57,7 @@ func Test_Consumer_Setups(t *testing.T) { })}, 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: nil}}, + 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}}, }, { @@ -67,7 +67,7 @@ func Test_Consumer_Setups(t *testing.T) { }, 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: nil}}, + 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}}, }, { @@ -87,7 +87,7 @@ func Test_Consumer_Setups(t *testing.T) { {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: nil}}, + 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}}, }, { @@ -106,7 +106,7 @@ func Test_Consumer_Setups(t *testing.T) { 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: 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 @@ -124,7 +124,7 @@ func Test_Consumer_Setups(t *testing.T) { }), }, 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: 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 From 98b7475ce40a460f04f13e40ab18fc0d4d3061a7 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 17 Apr 2025 09:56:02 +0200 Subject: [PATCH 48/50] feat: integrate typemapper for improved type handling Adds the typemapper library for enhanced message type mapping in AMQP. Modifies the Publisher and consumer setups to utilize the new mapper, allowing improved error handling and consistency in route mapping between types and routing keys. Updates tests to reflect changes in message handling and type validation. This refactor aims to simplify type management and reduce potential errors in message routing. --- connection.go | 15 ++---- consumer.go | 17 +++---- consumer_test.go | 4 +- examples/event-stream/example_test.go | 25 ++++++---- go.mod | 3 ++ request_response_test.go | 2 +- setup.go | 26 ++++++---- setup_consumer.go | 48 +++++-------------- setup_consumer_test.go | 68 +++++++++++---------------- setup_publisher.go | 24 ++++------ setup_test.go | 4 +- 11 files changed, 102 insertions(+), 134 deletions(-) diff --git a/connection.go b/connection.go index 4400fb1..89b8f4e 100644 --- a/connection.go +++ b/connection.go @@ -27,11 +27,12 @@ import ( "fmt" "io" "os" - "reflect" "runtime/debug" "time" amqp "github.com/rabbitmq/amqp091-go" + + "github.com/sparetimecoders/typemapper" ) // Connection is a wrapper around the actual amqp.Connection and amqp.Channel @@ -42,8 +43,7 @@ type Connection struct { connection amqpConnection channel AmqpChannel queueConsumers *queueConsumers - typeToKey typeToRoutingKey - keyToType routingKeyToType + mapper *typemapper.Mapper notificationCh chan<- Notification errorCh chan<- ErrorNotification spanNameFn func(DeliveryInfo) string @@ -216,14 +216,13 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { consumers: make(map[string]*queueConsumer), spanNameFn: spanNameFn, }, - keyToType: make(map[string]reflect.Type), - typeToKey: make(map[reflect.Type]string), + mapper: typemapper.New(), } } func (c *Connection) setup() error { for _, consumer := range (*c).queueConsumers.consumers { - if deliveries, err := consumer.consume(c.channel, c.keyToType, c.notificationCh, c.errorCh); err != nil { + 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) @@ -231,7 +230,3 @@ func (c *Connection) setup() error { } return nil } - -type routingKeyToType map[string]reflect.Type - -type typeToRoutingKey map[reflect.Type]string diff --git a/consumer.go b/consumer.go index ff45825..852034b 100644 --- a/consumer.go +++ b/consumer.go @@ -32,16 +32,14 @@ import ( ) type queueConsumer struct { - queue string - handlers routingKeyHandler - routingKeyToType routingKeyToType - notificationCh chan<- Notification - errorCh chan<- ErrorNotification - spanNameFn func(info DeliveryInfo) string + queue string + handlers routingKeyHandler + notificationCh chan<- Notification + errorCh chan<- ErrorNotification + spanNameFn func(info DeliveryInfo) string } -func (c *queueConsumer) consume(channel AmqpChannel, routingKeyToType routingKeyToType, notificationCh chan<- Notification, errorCh chan<- ErrorNotification) (<-chan amqp.Delivery, error) { - c.routingKeyToType = routingKeyToType +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) @@ -71,11 +69,10 @@ func (c *queueConsumer) handleDelivery(handler wrappedHandler, delivery amqp.Del headerCtx := extractToContext(delivery.Headers) tracingCtx, span := otel.Tracer("amqp").Start(headerCtx, c.spanNameFn(deliveryInfo)) defer span.End() - handlerCtx := injectRoutingKeyToTypeContext(tracingCtx, c.routingKeyToType) startTime := time.Now() uevt := unmarshalEvent{DeliveryInfo: deliveryInfo, Payload: delivery.Body} - if err := handler(handlerCtx, uevt); err != nil { + if err := handler(tracingCtx, uevt); err != nil { elapsed := time.Since(startTime).Milliseconds() notifyEventHandlerFailed(c.errorCh, deliveryInfo, elapsed, err) if errors.Is(err, ErrParseJSON) { diff --git a/consumer_test.go b/consumer_test.go index 2faa44e..26930f6 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -62,7 +62,7 @@ func Test_Consume(t *testing.T) { return deliveries, nil }} - deliveries, err := consumer.consume(channel, nil, nil, nil) + deliveries, err := consumer.consume(channel, nil, nil) require.NoError(t, err) delivery := <-deliveries require.Equal(t, "MESSAGE_ID", delivery.MessageId) @@ -77,7 +77,7 @@ func Test_Consume_Failing(t *testing.T) { return nil, fmt.Errorf("failed") }} - _, err := consumer.consume(channel, nil, nil, nil) + _, err := consumer.consume(channel, nil, nil) require.EqualError(t, err, "failed") } diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 25c069a..c3b8f6e 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -29,6 +29,7 @@ import ( "time" "github.com/sparetimecoders/goamqp" + "github.com/sparetimecoders/typemapper" ) var amqpURL = "amqp://user:password@localhost:5672" @@ -40,10 +41,15 @@ func Example_event_stream() { } orderServiceConnection := goamqp.Must(goamqp.NewFromURL("order-service", amqpURL)) orderPublisher := goamqp.NewPublisher() - err := orderServiceConnection.Start(ctx, + mapper, err := typemapper.NewFromMap(map[string]any{ + "Order.Created": OrderCreated{}, + "Order.Updated": OrderUpdated{}, + }) + checkError(err) + + err = orderServiceConnection.Start(ctx, goamqp.EventStreamPublisher(orderPublisher), - goamqp.WithTypeMapping("Order.Created", OrderCreated{}), - goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), + goamqp.WithTypeMappings(mapper), ) checkError(err) @@ -110,19 +116,20 @@ func (s *ShippingService) Stop() error { func (s *ShippingService) Start(ctx context.Context) error { s.connection = goamqp.Must(goamqp.NewFromURL("shipping-service", amqpURL)) - + mapper, _ := typemapper.NewFromMap(map[string]any{ + "Order.Created": OrderCreated{}, + "Order.Updated": OrderUpdated{}, + }) return s.connection.Start(ctx, - goamqp.WithTypeMapping("Order.Created", OrderCreated{}), - goamqp.WithTypeMapping("Order.Updated", OrderUpdated{}), - goamqp.EventStreamConsumer("#", goamqp.TypeMappingHandler(func(ctx context.Context, event goamqp.ConsumableEvent[any]) error { - switch event.Payload.(type) { + goamqp.EventStreamConsumer("#", goamqp.TypeMappingHandler(func(ctx context.Context, event any) error { + switch event.(type) { case *OrderCreated: s.output = append(s.output, "Order created") case *OrderUpdated: s.output = append(s.output, "Order deleted") } return nil - }), + }, mapper), ), ) } diff --git a/go.mod b/go.mod index 12564b8..5ebdd8b 100644 --- a/go.mod +++ b/go.mod @@ -25,8 +25,11 @@ require ( 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 + github.com/sparetimecoders/typemapper v0.0.0-20250417075500-9ddab7142fbe // indirect go.opentelemetry.io/otel/metric v1.22.0 // indirect golang.org/x/sys v0.16.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace github.com/sparetimecoders/typemapper => /Users/peter/source/stc/typemapper diff --git a/request_response_test.go b/request_response_test.go index c1dd163..de067bc 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -59,7 +59,7 @@ func Test_RequestResponseHandler(t *testing.T) { require.Len(t, (*conn).queueConsumers.consumers, 1) handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") - require.Equal(t, "github.com/sparetimecoders/goamqp.ServiceRequestConsumer[...].1", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + require.Equal(t, "github.com/sparetimecoders/goamqp.Test_RequestResponseHandler.Test_RequestResponseHandler.RequestResponseHandler[...].func6", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) missing, exists := conn.queueConsumers.get("miggins", "key") require.Nil(t, missing) require.False(t, exists) diff --git a/setup.go b/setup.go index f065542..b418bdc 100644 --- a/setup.go +++ b/setup.go @@ -29,28 +29,34 @@ import ( "runtime" amqp "github.com/rabbitmq/amqp091-go" + + "github.com/sparetimecoders/typemapper" ) // 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 { +// WithTypeMappings adds a two-way mapping between a type and a routing key. The mapping needs to be unique. +func WithTypeMappings(mapper *typemapper.Mapper) Setup { return func(conn *Connection) error { - typ := reflect.TypeOf(msgType) - if t, exists := conn.keyToType[routingKey]; exists && t != typ { - return fmt.Errorf("mapping for routing key '%s' already registered to type '%s'", routingKey, t) - } - if key, exists := conn.typeToKey[typ]; exists && key != routingKey { - return fmt.Errorf("mapping for type '%s' already registered to routing key '%s'", typ, key) + for _, k := range mapper.Keys() { + v, _ := mapper.Type(k) + if err := conn.mapper.Add(k, v); err != nil { + return err + } } - conn.keyToType[routingKey] = typ - conn.typeToKey[typ] = routingKey return nil } } +// WithTypeMapping adds a two-way mapping between a type and a routing key. The mapping needs to be unique. +func WithTypeMapping(routingKey string, typ any) Setup { + return func(conn *Connection) error { + return conn.mapper.Add(routingKey, typ) + } +} + // 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 diff --git a/setup_consumer.go b/setup_consumer.go index 015e587..e889606 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -29,13 +29,14 @@ import ( "reflect" amqp "github.com/rabbitmq/amqp091-go" + + "github.com/sparetimecoders/typemapper" ) type ( - // Handler is the type definition for a function that is used to handle events that has been mapped with - // RoutingKey <-> Type mappings from WithTypeMapping. + // Handler is the type definition for a function that is used to handle "raw" events, without the ConsumableEvent parts. // If processing fails, an error should be returned and the message will be re-queued - Handler func(ctx context.Context, event ConsumableEvent[any]) error + Handler func(ctx context.Context, event any) error // EventHandler is the type definition for 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 @@ -65,23 +66,20 @@ func LegacyHandler[T any](handler HandlerFunc, typ T) EventHandler[T] { // TypeMappingHandler wraps a Handler func into an EventHandler in order to use it with the different // Consumer Setup func. -// It will use the mappings from WithTypeMapping to determine routing key -> actual event type and pass it to the -// handler func. -func TypeMappingHandler(handler Handler) EventHandler[json.RawMessage] { +func TypeMappingHandler(handler Handler, routingKeyToType *typemapper.Mapper) EventHandler[json.RawMessage] { return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) error { - message, exists := routingKeyToTypeFromContext(ctx, event.DeliveryInfo.RoutingKey) + if routingKeyToType == nil { + return fmt.Errorf("mapper is nil") + } + message, exists := routingKeyToType.Type(event.DeliveryInfo.RoutingKey) if !exists { return ErrNoMessageTypeForRouteKey } - if err := json.Unmarshal(event.Payload, &message); err != nil { + payload := reflect.New(message).Interface() + if err := json.Unmarshal(event.Payload, &payload); err != nil { return fmt.Errorf("%v: %w", err, ErrParseJSON) } - msg := ConsumableEvent[any]{ - Metadata: event.Metadata, - DeliveryInfo: event.DeliveryInfo, - Payload: message, - } - return handler(ctx, msg) + return handler(ctx, payload) } } @@ -190,25 +188,3 @@ func TransientStreamConsumer[T any](exchange, routingKey string, handler EventHa return c.messageHandlerBindQueueToExchange(config) } } - -// Handles WithTypeMapping mappings in context.Context -type routingKeyToTypeCtx string - -const routingKeyToTypeCtxProperty routingKeyToTypeCtx = "routingKeyToType" - -func injectRoutingKeyToTypeContext(ctx context.Context, keyToType routingKeyToType) context.Context { - return context.WithValue(ctx, routingKeyToTypeCtxProperty, keyToType) -} - -func routingKeyToTypeFromContext(ctx context.Context, routingKey string) (any, bool) { - keyToType, ok := ctx.Value(routingKeyToTypeCtxProperty).(routingKeyToType) - if !ok { - return nil, false - } - - typ, exists := keyToType[routingKey] - if !exists { - return nil, false - } - return reflect.New(typ).Interface(), true -} diff --git a/setup_consumer_test.go b/setup_consumer_test.go index 62953b3..e51bccb 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -28,13 +28,14 @@ import ( "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" + + "github.com/sparetimecoders/typemapper" ) func Test_Consumer_Setups(t *testing.T) { @@ -168,28 +169,9 @@ func Test_Consumer_Setups(t *testing.T) { } } -func Test_MappingsInContext(t *testing.T) { - mappings := routingKeyToType{ - "string": reflect.TypeOf(""), - "double": reflect.TypeOf(1.0), - } - rootCtx := context.TODO() - ctx := injectRoutingKeyToTypeContext(rootCtx, mappings) - instance, ok := routingKeyToTypeFromContext(ctx, "string") - require.True(t, ok) - require.IsType(t, reflect.TypeOf(instance), reflect.TypeOf("")) - - _, ok = routingKeyToTypeFromContext(ctx, "int") - require.False(t, ok) - - // This should always fail - _, ok = routingKeyToTypeFromContext(rootCtx, "string") - require.False(t, ok) -} - func Test_TypeMappingHandler(t *testing.T) { type fields struct { - keyToType map[string]reflect.Type + mapper *typemapper.Mapper } type args struct { handler func(t *testing.T) Handler @@ -203,13 +185,16 @@ func Test_TypeMappingHandler(t *testing.T) { wantErr assert.ErrorAssertionFunc }{ { - name: "no mapped type, ignored", - fields: fields{}, + name: "no mapped type, ignored", + fields: fields{ + mapper: typemapper.New(), + }, args: args{ msg: []byte(`{"a":true}`), key: "unknown", handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { + return func(ctx context.Context, event any) error { + t.Fail() return nil } }, @@ -221,15 +206,16 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "parse error", fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, + mapper: func() *typemapper.Mapper { + m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) + return m + }(), }, args: args{ msg: []byte(`{"a:}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { + return func(ctx context.Context, event any) error { return nil } }, @@ -241,16 +227,17 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "handler error", fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, + mapper: func() *typemapper.Mapper { + m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) + return m + }(), }, args: args{ msg: []byte(`{"a":true}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - assert.IsType(t, &TestMessage{}, event.Payload) + return func(ctx context.Context, event any) error { + assert.IsType(t, &TestMessage{}, event) return fmt.Errorf("handler-error") } }, @@ -262,16 +249,17 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "success", fields: fields{ - keyToType: map[string]reflect.Type{ - "known": reflect.TypeOf(TestMessage{}), - }, + mapper: func() *typemapper.Mapper { + m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) + return m + }(), }, args: args{ msg: []byte(`{"a":true}`), key: "known", handler: func(t *testing.T) Handler { - return func(ctx context.Context, event ConsumableEvent[any]) error { - assert.IsType(t, &TestMessage{}, event.Payload) + return func(ctx context.Context, event any) error { + assert.IsType(t, &TestMessage{}, event) return nil } }, @@ -281,9 +269,9 @@ func Test_TypeMappingHandler(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ctx := injectRoutingKeyToTypeContext(context.TODO(), tt.fields.keyToType) + ctx := context.TODO() - handler := TypeMappingHandler(tt.args.handler(t)) + handler := TypeMappingHandler(tt.args.handler(t), tt.fields.mapper) err := handler(ctx, ConsumableEvent[json.RawMessage]{ Payload: tt.args.msg, DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, diff --git a/setup_publisher.go b/setup_publisher.go index fd17603..693a976 100644 --- a/setup_publisher.go +++ b/setup_publisher.go @@ -26,14 +26,15 @@ import ( "context" "encoding/json" "fmt" - "reflect" amqp "github.com/rabbitmq/amqp091-go" + + "github.com/sparetimecoders/typemapper" ) // Publisher is used to send messages type Publisher struct { - typeToKey typeToRoutingKey + mapper *typemapper.Mapper channel AmqpChannel exchange string defaultHeaders []Header @@ -70,15 +71,10 @@ func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) err table[h.Key] = h.Value } - t := reflect.TypeOf(msg) - key := t - if t.Kind() == reflect.Ptr { - key = t.Elem() - } - if key, ok := p.typeToKey[key]; ok { + if key, ok := p.mapper.Key(msg); ok { return publishMessage(ctx, p.channel, msg, key, p.exchange, table) } - return fmt.Errorf("%w %s", ErrNoRouteForMessageType, t) + return fmt.Errorf("%w %T", ErrNoRouteForMessageType, msg) } // EventStreamPublisher sets up an event stream publisher @@ -93,7 +89,7 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { 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, c.typeToKey) + return publisher.setup(c.channel, c.serviceName, exchangeName, c.mapper) } } @@ -102,7 +98,7 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { // 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, "", c.typeToKey, Header{Key: "CC", Value: []any{destinationQueueName}}) + return publisher.setup(c.channel, c.serviceName, "", c.mapper, Header{Key: "CC", Value: []any{destinationQueueName}}) } } @@ -113,11 +109,11 @@ func ServicePublisher(targetService string, publisher *Publisher) Setup { if err := exchangeDeclare(c.channel, exchangeName, amqp.ExchangeDirect); err != nil { return err } - return publisher.setup(c.channel, c.serviceName, exchangeName, c.typeToKey) + return publisher.setup(c.channel, c.serviceName, exchangeName, c.mapper) } } -func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, typeToKey typeToRoutingKey, headers ...Header) error { +func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, mapper *typemapper.Mapper, headers ...Header) error { for _, h := range headers { if err := h.validateKey(); err != nil { return err @@ -125,7 +121,7 @@ func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, typ } p.defaultHeaders = append(headers, Header{Key: headerService, Value: serviceName}) p.channel = channel - p.typeToKey = typeToKey + p.mapper = mapper p.exchange = exchange return nil } diff --git a/setup_test.go b/setup_test.go index 0af41a0..414e32d 100644 --- a/setup_test.go +++ b/setup_test.go @@ -86,7 +86,7 @@ func Test_WithTypeMapping_KeyAlreadyExist(t *testing.T) { err := WithTypeMapping("key", TestMessage{})(conn) require.NoError(t, err) err = WithTypeMapping("key", TestMessage2{})(conn) - require.EqualError(t, err, "mapping for routing key 'key' already registered to type 'goamqp.TestMessage'") + require.EqualError(t, err, "mapping for key 'key' already registered to type 'goamqp.TestMessage'") err = WithTypeMapping("key", TestMessage{})(conn) require.NoError(t, err) @@ -98,7 +98,7 @@ func Test_WithTypeMapping_TypeAlreadyExist(t *testing.T) { err := WithTypeMapping("key", TestMessage{})(conn) require.NoError(t, err) err = WithTypeMapping("other", TestMessage{})(conn) - require.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to routing key 'key'") + require.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to key 'key'") err = WithTypeMapping("key", TestMessage{})(conn) require.NoError(t, err) From e07eb56b03f0bcc0b82e88ad2d5ebf7d177eeca2 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 15 May 2025 15:41:01 +0200 Subject: [PATCH 49/50] feat: update publisher setup to simplify arguments Refactor the Publisher setup method to remove unnecessary parameters. Adjust the integration test to handle the new publish call. Update go.mod to specify the Go toolchain version. Refine test cases for clearer header handling and update expected behavior in tests to align with latest changes. --- connection.go | 4 -- connection_test.go | 2 +- consumer_test.go | 26 +++++++++ example_test.go | 3 +- examples/event-stream/example_test.go | 35 ++++++------ examples/request-response/example_test.go | 3 +- go.mod | 9 ++-- go.sum | 4 +- handler.go | 16 ++++++ integration/integration_test.go | 24 +++------ request_response_test.go | 2 +- setup.go | 22 -------- setup_consumer.go | 47 +++++----------- setup_consumer_test.go | 65 +++++++++++++---------- setup_publisher.go | 26 +++------ setup_publisher_test.go | 58 ++++++++------------ setup_test.go | 24 --------- 17 files changed, 156 insertions(+), 214 deletions(-) diff --git a/connection.go b/connection.go index 89b8f4e..a84da8e 100644 --- a/connection.go +++ b/connection.go @@ -31,8 +31,6 @@ import ( "time" amqp "github.com/rabbitmq/amqp091-go" - - "github.com/sparetimecoders/typemapper" ) // Connection is a wrapper around the actual amqp.Connection and amqp.Channel @@ -43,7 +41,6 @@ type Connection struct { connection amqpConnection channel AmqpChannel queueConsumers *queueConsumers - mapper *typemapper.Mapper notificationCh chan<- Notification errorCh chan<- ErrorNotification spanNameFn func(DeliveryInfo) string @@ -216,7 +213,6 @@ func newConnection(serviceName string, uri amqp.URI) *Connection { consumers: make(map[string]*queueConsumer), spanNameFn: spanNameFn, }, - mapper: typemapper.New(), } } diff --git a/connection_test.go b/connection_test.go index 5726cf4..a69518f 100644 --- a/connection_test.go +++ b/connection_test.go @@ -396,7 +396,7 @@ func TestResponseWrapper(t *testing.T) { func Test_Publisher_ReservedHeader(t *testing.T) { p := NewPublisher() - err := p.Publish(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") } diff --git a/consumer_test.go b/consumer_test.go index 26930f6..c2b81e5 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -33,6 +33,32 @@ import ( "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 diff --git a/example_test.go b/example_test.go index 40b3380..19e0834 100644 --- a/example_test.go +++ b/example_test.go @@ -40,12 +40,11 @@ func Example() { connection := Must(NewFromURL("service", amqpURL)) err := connection.Start(ctx, - WithTypeMapping("key", IncomingMessage{}), EventStreamConsumer("key", process), EventStreamPublisher(publisher), ) checkError(err) - err = publisher.Publish(ctx, IncomingMessage{"OK"}) + err = publisher.Publish(ctx, "key", IncomingMessage{"OK"}) checkError(err) time.Sleep(time.Second) err = connection.Close() diff --git a/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index c3b8f6e..99534fe 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -26,10 +26,10 @@ import ( "context" "fmt" "os" + "reflect" "time" "github.com/sparetimecoders/goamqp" - "github.com/sparetimecoders/typemapper" ) var amqpURL = "amqp://user:password@localhost:5672" @@ -41,15 +41,9 @@ func Example_event_stream() { } orderServiceConnection := goamqp.Must(goamqp.NewFromURL("order-service", amqpURL)) orderPublisher := goamqp.NewPublisher() - mapper, err := typemapper.NewFromMap(map[string]any{ - "Order.Created": OrderCreated{}, - "Order.Updated": OrderUpdated{}, - }) - checkError(err) - err = orderServiceConnection.Start(ctx, + err := orderServiceConnection.Start(ctx, goamqp.EventStreamPublisher(orderPublisher), - goamqp.WithTypeMappings(mapper), ) checkError(err) @@ -61,9 +55,9 @@ func Example_event_stream() { err = statService.Start(ctx) checkError(err) - err = orderPublisher.Publish(context.Background(), OrderCreated{Id: "id"}) + err = orderPublisher.Publish(context.Background(), "Order.Created", OrderCreated{Id: "id"}) checkError(err) - err = orderPublisher.Publish(context.Background(), OrderUpdated{Id: "id", Data: "data"}) + err = orderPublisher.Publish(context.Background(), "Order.Updated", OrderUpdated{Id: "id", Data: "data"}) checkError(err) time.Sleep(2 * time.Second) _ = orderServiceConnection.Close() @@ -116,22 +110,25 @@ func (s *ShippingService) Stop() error { func (s *ShippingService) Start(ctx context.Context) error { s.connection = goamqp.Must(goamqp.NewFromURL("shipping-service", amqpURL)) - mapper, _ := typemapper.NewFromMap(map[string]any{ - "Order.Created": OrderCreated{}, - "Order.Updated": OrderUpdated{}, - }) return s.connection.Start(ctx, - goamqp.EventStreamConsumer("#", goamqp.TypeMappingHandler(func(ctx context.Context, event any) error { - switch event.(type) { + 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 - }, mapper), - ), - ) + }, 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) { diff --git a/examples/request-response/example_test.go b/examples/request-response/example_test.go index 49b831d..29c9680 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -49,13 +49,12 @@ func Example_request_response() { publisher := goamqp.NewPublisher() err = clientConnection.Start(ctx, - goamqp.WithTypeMapping(routingKey, Request{}), goamqp.ServicePublisher("service", publisher), goamqp.ServiceResponseConsumer("service", routingKey, handleResponse), ) checkError(err) - err = publisher.Publish(context.Background(), Request{Data: "test"}) + err = publisher.Publish(context.Background(), "key", Request{Data: "test"}) checkError(err) time.Sleep(time.Second) diff --git a/go.mod b/go.mod index 5ebdd8b..f08c7d9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,8 @@ module github.com/sparetimecoders/goamqp -go 1.23 +go 1.23.0 + +toolchain go1.24.2 require ( github.com/google/uuid v1.6.0 @@ -25,11 +27,8 @@ require ( 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 - github.com/sparetimecoders/typemapper v0.0.0-20250417075500-9ddab7142fbe // indirect go.opentelemetry.io/otel/metric v1.22.0 // indirect - golang.org/x/sys v0.16.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 ) - -replace github.com/sparetimecoders/typemapper => /Users/peter/source/stc/typemapper diff --git a/go.sum b/go.sum index 7961fdc..0b02733 100644 --- a/go.sum +++ b/go.sum @@ -48,8 +48,8 @@ go.opentelemetry.io/otel/trace v1.22.0 h1:Hg6pPujv0XG9QaVbGOBVHunyuLcCC3jN7WEhPx 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= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= diff --git a/handler.go b/handler.go index ec81025..624a68e 100644 --- a/handler.go +++ b/handler.go @@ -27,6 +27,7 @@ import ( "encoding/json" "errors" "fmt" + "reflect" ) var ErrParseJSON = errors.New("failed to parse") @@ -36,11 +37,26 @@ var ErrParseJSON = errors.New("failed to parse") 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) diff --git a/integration/integration_test.go b/integration/integration_test.go index d4d9785..38d7a52 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -137,7 +137,6 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { serverReceived = msg.Payload return IncomingResponse{Value: serverReceived.Query}, nil }), - WithTypeMapping(routingKey, Incoming{}), ) defer server.Close() @@ -146,7 +145,6 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { var clientReceived IncomingResponse client := createConnection(suite, "client", ServicePublisher(serverServiceName, publish), - WithTypeMapping(routingKey, Incoming{}), ServiceResponseConsumer(serverServiceName, routingKey, func(ctx context.Context, msg ConsumableEvent[IncomingResponse]) error { clientReceived = msg.Payload closer <- true @@ -154,7 +152,7 @@ func (suite *IntegrationTestSuite) Test_RequestResponse() { })) defer client.Close() - err := publish.Publish(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), "routingKey", &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) <-closer @@ -245,8 +243,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { clientQuery := "test" publish := NewPublisher() server := createConnection(suite, serverServiceName, - EventStreamPublisher(publish), - WithTypeMapping(routingKey, Incoming{})) + EventStreamPublisher(publish)) defer server.Close() var client1Received Incoming @@ -268,7 +265,7 @@ func (suite *IntegrationTestSuite) Test_EventStream_MultipleConsumers() { ) defer client2.Close() - err := publish.Publish(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), routingKey, &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) <-closer @@ -356,8 +353,6 @@ func (suite *IntegrationTestSuite) Test_EventStream() { publish := NewPublisher() server := createConnection(suite, serverServiceName, EventStreamPublisher(publish), - WithTypeMapping(routingKey1, Incoming{}), - WithTypeMapping(routingKey2, IncomingResponse{}), ) defer server.Close() @@ -381,9 +376,9 @@ func (suite *IntegrationTestSuite) Test_EventStream() { ) defer client1.Close() - err := publish.Publish(context.Background(), &Incoming{Query: clientQuery}) + err := publish.Publish(context.Background(), routingKey1, &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.Publish(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), routingKey2, &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) <-closer @@ -480,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() @@ -507,11 +499,11 @@ func (suite *IntegrationTestSuite) Test_WildcardRoutingKeys() { })) defer client1.Close() - err := publish.Publish(context.Background(), &Test{Test: clientQuery}) + err := publish.Publish(context.Background(), "1.2.test.2", &Test{Test: clientQuery}) require.NoError(suite.T(), err) - err = publish.Publish(context.Background(), &Incoming{Query: clientQuery}) + err = publish.Publish(context.Background(), "test.1", &Incoming{Query: clientQuery}) require.NoError(suite.T(), err) - err = publish.Publish(context.Background(), &IncomingResponse{Value: clientQuery}) + err = publish.Publish(context.Background(), exactMatchRoutingKey, &IncomingResponse{Value: clientQuery}) require.NoError(suite.T(), err) <-closer diff --git a/request_response_test.go b/request_response_test.go index de067bc..b95b1d0 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -59,7 +59,7 @@ func Test_RequestResponseHandler(t *testing.T) { require.Len(t, (*conn).queueConsumers.consumers, 1) handler, _ := conn.queueConsumers.get("svc.direct.exchange.request.queue", "key") - require.Equal(t, "github.com/sparetimecoders/goamqp.Test_RequestResponseHandler.Test_RequestResponseHandler.RequestResponseHandler[...].func6", runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()) + 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) diff --git a/setup.go b/setup.go index b418bdc..06b57c6 100644 --- a/setup.go +++ b/setup.go @@ -29,34 +29,12 @@ import ( "runtime" amqp "github.com/rabbitmq/amqp091-go" - - "github.com/sparetimecoders/typemapper" ) // 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 -// WithTypeMappings adds a two-way mapping between a type and a routing key. The mapping needs to be unique. -func WithTypeMappings(mapper *typemapper.Mapper) Setup { - return func(conn *Connection) error { - for _, k := range mapper.Keys() { - v, _ := mapper.Type(k) - if err := conn.mapper.Add(k, v); err != nil { - return err - } - } - return nil - } -} - -// WithTypeMapping adds a two-way mapping between a type and a routing key. The mapping needs to be unique. -func WithTypeMapping(routingKey string, typ any) Setup { - return func(conn *Connection) error { - return conn.mapper.Add(routingKey, typ) - } -} - // 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 diff --git a/setup_consumer.go b/setup_consumer.go index e889606..60d86b4 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -29,57 +29,38 @@ import ( "reflect" amqp "github.com/rabbitmq/amqp091-go" - - "github.com/sparetimecoders/typemapper" ) type ( - // Handler is the type definition for a function that is used to handle "raw" events, without the ConsumableEvent parts. - // If processing fails, an error should be returned and the message will be re-queued - Handler func(ctx context.Context, event any) error - // EventHandler is the type definition for a function that is used to handle events of a specific 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 the type definition for a function that is used to handle events of a specific + // 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) ) -// 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 -// Deprecated: only kept as a convenience for upgrading to new handler functions will be removed in future releases -type HandlerFunc func(msg any, headers Headers) (response any, err error) - -// LegacyHandler provides a way to use old handler functions and type registration -// Deprecated: only provided as a convenience for upgrading to new handler functions will be removed in future releases -func LegacyHandler[T any](handler HandlerFunc, typ T) EventHandler[T] { - return func(ctx context.Context, event ConsumableEvent[T]) error { - _, err := handler(&event.Payload, event.DeliveryInfo.Headers) - if err != nil { - return err - } - return nil - } -} - -// TypeMappingHandler wraps a Handler func into an EventHandler in order to use it with the different -// Consumer Setup func. -func TypeMappingHandler(handler Handler, routingKeyToType *typemapper.Mapper) EventHandler[json.RawMessage] { - return func(ctx context.Context, event ConsumableEvent[json.RawMessage]) 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") } - message, exists := routingKeyToType.Type(event.DeliveryInfo.RoutingKey) + + typ, exists := routingKeyToType(event.DeliveryInfo.RoutingKey) if !exists { return ErrNoMessageTypeForRouteKey } - payload := reflect.New(message).Interface() - if err := json.Unmarshal(event.Payload, &payload); err != nil { + payload := reflect.New(typ.Elem()).Interface() + if err := json.Unmarshal(event.Payload.(json.RawMessage), &payload); err != nil { return fmt.Errorf("%v: %w", err, ErrParseJSON) } - return handler(ctx, payload) + event.Payload = payload + return handler(ctx, event) } } diff --git a/setup_consumer_test.go b/setup_consumer_test.go index e51bccb..80f9429 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -28,14 +28,13 @@ import ( "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" - - "github.com/sparetimecoders/typemapper" ) func Test_Consumer_Setups(t *testing.T) { @@ -171,10 +170,10 @@ func Test_Consumer_Setups(t *testing.T) { func Test_TypeMappingHandler(t *testing.T) { type fields struct { - mapper *typemapper.Mapper + mapper TypeMapper } type args struct { - handler func(t *testing.T) Handler + handler func(t *testing.T) EventHandler[any] msg json.RawMessage key string } @@ -187,13 +186,15 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "no mapped type, ignored", fields: fields{ - mapper: typemapper.New(), + 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) Handler { - return func(ctx context.Context, event any) error { + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { t.Fail() return nil } @@ -206,16 +207,18 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "parse error", fields: fields{ - mapper: func() *typemapper.Mapper { - m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) - return m - }(), + 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) Handler { - return func(ctx context.Context, event any) error { + handler: func(t *testing.T) EventHandler[any] { + return func(ctx context.Context, event ConsumableEvent[any]) error { return nil } }, @@ -227,17 +230,20 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "handler error", fields: fields{ - mapper: func() *typemapper.Mapper { - m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) - return m - }(), + 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) Handler { - return func(ctx context.Context, event any) error { - assert.IsType(t, &TestMessage{}, event) + 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") } }, @@ -249,17 +255,20 @@ func Test_TypeMappingHandler(t *testing.T) { { name: "success", fields: fields{ - mapper: func() *typemapper.Mapper { - m, _ := typemapper.NewFromMap(map[string]any{"known": TestMessage{}}) - return m - }(), + 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) Handler { - return func(ctx context.Context, event any) error { - assert.IsType(t, &TestMessage{}, event) + 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 } }, @@ -272,7 +281,7 @@ func Test_TypeMappingHandler(t *testing.T) { ctx := context.TODO() handler := TypeMappingHandler(tt.args.handler(t), tt.fields.mapper) - err := handler(ctx, ConsumableEvent[json.RawMessage]{ + err := handler(ctx, ConsumableEvent[any]{ Payload: tt.args.msg, DeliveryInfo: DeliveryInfo{RoutingKey: tt.args.key}, }) diff --git a/setup_publisher.go b/setup_publisher.go index 693a976..fa1bf7d 100644 --- a/setup_publisher.go +++ b/setup_publisher.go @@ -28,21 +28,16 @@ import ( "fmt" amqp "github.com/rabbitmq/amqp091-go" - - "github.com/sparetimecoders/typemapper" ) // Publisher is used to send messages type Publisher struct { - mapper *typemapper.Mapper channel AmqpChannel exchange string defaultHeaders []Header } -// ErrNoRouteForMessageType when the published message cannot be routed. var ( - ErrNoRouteForMessageType = fmt.Errorf("no routingkey configured for message of type") ErrNoMessageTypeForRouteKey = fmt.Errorf("no message type for routingkey configured") ) @@ -53,13 +48,12 @@ func NewPublisher() *Publisher { // PublishWithContext wraps Publish to ease migration to new version of goamqp // Deprecated: use Publish directly -func (p *Publisher) PublishWithContext(ctx context.Context, msg any, headers ...Header) error { - return p.Publish(ctx, msg, headers...) +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. -// It requires RoutingKey <-> Type mappings from WithTypeMapping in order to set the correct Routing Key for msg -func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) error { +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 @@ -71,10 +65,7 @@ func (p *Publisher) Publish(ctx context.Context, msg any, headers ...Header) err table[h.Key] = h.Value } - if key, ok := p.mapper.Key(msg); ok { - return publishMessage(ctx, p.channel, msg, key, p.exchange, table) - } - return fmt.Errorf("%w %T", ErrNoRouteForMessageType, msg) + return publishMessage(ctx, p.channel, msg, routingkKey, p.exchange, table) } // EventStreamPublisher sets up an event stream publisher @@ -89,7 +80,7 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { 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, c.mapper) + return publisher.setup(c.channel, c.serviceName, exchangeName) } } @@ -98,7 +89,7 @@ func StreamPublisher(exchange string, publisher *Publisher) Setup { // 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, "", c.mapper, Header{Key: "CC", Value: []any{destinationQueueName}}) + return publisher.setup(c.channel, c.serviceName, "", Header{Key: "CC", Value: []any{destinationQueueName}}) } } @@ -109,11 +100,11 @@ func ServicePublisher(targetService string, publisher *Publisher) Setup { if err := exchangeDeclare(c.channel, exchangeName, amqp.ExchangeDirect); err != nil { return err } - return publisher.setup(c.channel, c.serviceName, exchangeName, c.mapper) + return publisher.setup(c.channel, c.serviceName, exchangeName) } } -func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, mapper *typemapper.Mapper, headers ...Header) error { +func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, headers ...Header) error { for _, h := range headers { if err := h.validateKey(); err != nil { return err @@ -121,7 +112,6 @@ func (p *Publisher) setup(channel AmqpChannel, serviceName, exchange string, map } p.defaultHeaders = append(headers, Header{Key: headerService, Value: serviceName}) p.channel = channel - p.mapper = mapper p.exchange = exchange return nil } diff --git a/setup_publisher_test.go b/setup_publisher_test.go index 910d9c3..9f9c4f6 100644 --- a/setup_publisher_test.go +++ b/setup_publisher_test.go @@ -25,6 +25,8 @@ package goamqp import ( "context" "encoding/json" + "maps" + "slices" "testing" "github.com/google/uuid" @@ -39,7 +41,7 @@ func Test_Publisher_Setups(t *testing.T) { tests := []struct { name string opts func(p *Publisher) []Setup - messages []any + messages map[string]any expectedError string expectedExchanges []ExchangeDeclaration expectedQueues []QueueDeclaration @@ -50,9 +52,9 @@ func Test_Publisher_Setups(t *testing.T) { { name: "EventStreamConsumer", opts: func(p *Publisher) []Setup { - return []Setup{QueuePublisher(p, "destQueue"), WithTypeMapping("key", TestMessage{})} + return []Setup{QueuePublisher(p, "destQueue")} }, - messages: []any{TestMessage{"test", true}}, + messages: map[string]any{"key": TestMessage{"test", true}}, headers: []Header{{"x-header", "header"}}, expectedPublished: []*Publish{{ exchange: "", @@ -70,9 +72,9 @@ func Test_Publisher_Setups(t *testing.T) { { name: "EventStreamPublisher", opts: func(p *Publisher) []Setup { - return []Setup{EventStreamPublisher(p), WithTypeMapping("key", TestMessage{})} + return []Setup{EventStreamPublisher(p)} }, - messages: []any{TestMessage{"test", true}}, + 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{{ @@ -91,10 +93,10 @@ func Test_Publisher_Setups(t *testing.T) { { name: "ServicePublisher", opts: func(p *Publisher) []Setup { - return []Setup{ServicePublisher("svc", p), WithTypeMapping("key", TestMessage{})} + return []Setup{ServicePublisher("svc", p)} }, expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, - messages: []any{TestMessage{"test", true}}, + messages: map[string]any{"key": TestMessage{"test", true}}, expectedPublished: []*Publish{{ exchange: serviceRequestExchangeName("svc"), key: "key", @@ -111,13 +113,12 @@ func Test_Publisher_Setups(t *testing.T) { { name: "ServicePublisher - multiple", opts: func(p *Publisher) []Setup { - return []Setup{ServicePublisher("svc", p), WithTypeMapping("key1", TestMessage{}), WithTypeMapping("key2", TestMessage2{})} + return []Setup{ServicePublisher("svc", p)} }, expectedExchanges: []ExchangeDeclaration{{name: serviceRequestExchangeName("svc"), noWait: false, internal: false, autoDelete: false, durable: true, kind: amqp.ExchangeDirect, args: nil}}, - messages: []any{ - TestMessage{"test", true}, - TestMessage2{"test", false}, - TestMessage{"test", false}, + messages: map[string]any{ + "key1": TestMessage{"test", true}, + "key2": TestMessage2{"test", false}, }, expectedPublished: []*Publish{ { @@ -142,31 +143,9 @@ func Test_Publisher_Setups(t *testing.T) { ContentEncoding: "", DeliveryMode: 2, }, - }, { - exchange: serviceRequestExchangeName("svc"), - key: "key1", - mandatory: false, - immediate: false, - msg: amqp.Publishing{ - Headers: amqp.Table{"service": "svc"}, - ContentType: contentType, - ContentEncoding: "", - DeliveryMode: 2, - }, }, }, }, - { - name: "no route", - 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: []any{ - TestMessage{"test", true}, - }, - expectedError: "no routingkey configured for message of type goamqp.TestMessage", - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -192,8 +171,12 @@ func Test_Publisher_Setups(t *testing.T) { require.Len(t, channel.BindingDeclarations, 0) } - for i, msg := range tt.messages { - err := p.Publish(ctx, msg, tt.headers...) + 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 @@ -208,12 +191,13 @@ func Test_Publisher_Setups(t *testing.T) { } else if tt.expectedError == "" { require.Fail(t, "nothing published, and no error wanted!") } + i++ } }) } } func Test_InvalidHeader(t *testing.T) { - err := (&Publisher{}).setup(nil, "", "", nil, Header{Key: "", Value: ""}) + err := (&Publisher{}).setup(nil, "", "", Header{Key: "", Value: ""}) require.ErrorIs(t, err, ErrEmptyHeaderKey) } diff --git a/setup_test.go b/setup_test.go index 414e32d..5b86008 100644 --- a/setup_test.go +++ b/setup_test.go @@ -80,30 +80,6 @@ func Test_PublishNotify(t *testing.T) { require.Equal(t, true, channel.ConfirmCalled) } -func Test_WithTypeMapping_KeyAlreadyExist(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := WithTypeMapping("key", TestMessage{})(conn) - require.NoError(t, err) - err = WithTypeMapping("key", TestMessage2{})(conn) - require.EqualError(t, err, "mapping for key 'key' already registered to type 'goamqp.TestMessage'") - - err = WithTypeMapping("key", TestMessage{})(conn) - require.NoError(t, err) -} - -func Test_WithTypeMapping_TypeAlreadyExist(t *testing.T) { - channel := NewMockAmqpChannel() - conn := mockConnection(channel) - err := WithTypeMapping("key", TestMessage{})(conn) - require.NoError(t, err) - err = WithTypeMapping("other", TestMessage{})(conn) - require.EqualError(t, err, "mapping for type 'goamqp.TestMessage' already registered to key 'key'") - - err = WithTypeMapping("key", TestMessage{})(conn) - require.NoError(t, err) -} - func Test_Start_WithPrefetchLimit_Resets_Qos(t *testing.T) { mockAmqpConnection := &MockAmqpConnection{ChannelConnected: true} mockChannel := &MockAmqpChannel{ From 9ac31c08ab338912687db0b82d96c53ebab2c7f7 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Wed, 18 Jun 2025 10:24:18 +0200 Subject: [PATCH 50/50] chore: update copyright year to 2025 across files This update changes the copyright year from 2024 to 2025 in all relevant files to ensure accurate copyright information for the current year. --- LICENSE | 2 +- channel.go | 2 +- connection.go | 2 +- connection_test.go | 2 +- consumableEvent.go | 2 +- consumer.go | 2 +- consumer_test.go | 2 +- doc.go | 2 +- example_test.go | 2 +- examples/event-stream/example_test.go | 2 +- examples/request-response/example_test.go | 2 +- handler.go | 2 +- headers.go | 2 +- headers_test.go | 2 +- integration/amqp_admin.go | 2 +- integration/integration_test.go | 2 +- integration/messages.go | 2 +- integration/tracing_test.go | 2 +- metrics.go | 2 +- metrics_test.go | 2 +- mocks_test.go | 2 +- must.go | 2 +- must_test.go | 2 +- naming.go | 2 +- naming_test.go | 2 +- notifications.go | 2 +- queue_binding_config.go | 2 +- queue_binding_config_test.go | 2 +- request_response.go | 2 +- request_response_test.go | 2 +- routingkey_handlers.go | 2 +- routingkey_handlers_test.go | 2 +- setup.go | 2 +- setup_consumer.go | 2 +- setup_consumer_test.go | 2 +- setup_publisher.go | 2 +- setup_publisher_test.go | 2 +- setup_test.go | 2 +- tracing.go | 2 +- 39 files changed, 39 insertions(+), 39 deletions(-) diff --git a/LICENSE b/LICENSE index 09907a6..e95a576 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 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/channel.go b/channel.go index 59f23a0..4ff2ccc 100644 --- a/channel.go +++ b/channel.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/connection.go b/connection.go index a84da8e..aff018a 100644 --- a/connection.go +++ b/connection.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/connection_test.go b/connection_test.go index a69518f..8be59b2 100644 --- a/connection_test.go +++ b/connection_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/consumableEvent.go b/consumableEvent.go index baadded..6ca93c1 100644 --- a/consumableEvent.go +++ b/consumableEvent.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/consumer.go b/consumer.go index 852034b..5840d34 100644 --- a/consumer.go +++ b/consumer.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/consumer_test.go b/consumer_test.go index c2b81e5..441f82f 100644 --- a/consumer_test.go +++ b/consumer_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/doc.go b/doc.go index 2bf99fa..413d0a0 100644 --- a/doc.go +++ b/doc.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/example_test.go b/example_test.go index 19e0834..8519594 100644 --- a/example_test.go +++ b/example_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/examples/event-stream/example_test.go b/examples/event-stream/example_test.go index 99534fe..7f5dde0 100644 --- a/examples/event-stream/example_test.go +++ b/examples/event-stream/example_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/examples/request-response/example_test.go b/examples/request-response/example_test.go index 29c9680..268cf61 100644 --- a/examples/request-response/example_test.go +++ b/examples/request-response/example_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/handler.go b/handler.go index 624a68e..762c327 100644 --- a/handler.go +++ b/handler.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/headers.go b/headers.go index e82e3ec..4a7c0bc 100644 --- a/headers.go +++ b/headers.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/headers_test.go b/headers_test.go index 26df7bd..0019d8c 100644 --- a/headers_test.go +++ b/headers_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/integration/amqp_admin.go b/integration/amqp_admin.go index 6376a1f..2a4a43f 100644 --- a/integration/amqp_admin.go +++ b/integration/amqp_admin.go @@ -3,7 +3,7 @@ // MIT License // -// Copyright (c) 2024 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/integration/integration_test.go b/integration/integration_test.go index 38d7a52..7a211b9 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -3,7 +3,7 @@ // MIT License // -// Copyright (c) 2024 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/integration/messages.go b/integration/messages.go index 0aa4491..6e0e29f 100644 --- a/integration/messages.go +++ b/integration/messages.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/integration/tracing_test.go b/integration/tracing_test.go index ecca20a..e9a9a1d 100644 --- a/integration/tracing_test.go +++ b/integration/tracing_test.go @@ -3,7 +3,7 @@ // MIT License // -// Copyright (c) 2024 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/metrics.go b/metrics.go index 8ee8b09..426de59 100644 --- a/metrics.go +++ b/metrics.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/metrics_test.go b/metrics_test.go index 870de1e..2033e68 100644 --- a/metrics_test.go +++ b/metrics_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/mocks_test.go b/mocks_test.go index 44ee000..c2fac08 100644 --- a/mocks_test.go +++ b/mocks_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/must.go b/must.go index 42ac4ad..966b735 100644 --- a/must.go +++ b/must.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/must_test.go b/must_test.go index 8bafd0c..0b54f4c 100644 --- a/must_test.go +++ b/must_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/naming.go b/naming.go index bfa51ba..2438142 100644 --- a/naming.go +++ b/naming.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/naming_test.go b/naming_test.go index ce397d4..48494ad 100644 --- a/naming_test.go +++ b/naming_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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 index 843a23f..44b150f 100644 --- a/notifications.go +++ b/notifications.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/queue_binding_config.go b/queue_binding_config.go index 14eeedc..e81b3ee 100644 --- a/queue_binding_config.go +++ b/queue_binding_config.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/queue_binding_config_test.go b/queue_binding_config_test.go index 2ac9173..6a4b6cd 100644 --- a/queue_binding_config_test.go +++ b/queue_binding_config_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/request_response.go b/request_response.go index 5c310b4..c1ea027 100644 --- a/request_response.go +++ b/request_response.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/request_response_test.go b/request_response_test.go index b95b1d0..b26682f 100644 --- a/request_response_test.go +++ b/request_response_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/routingkey_handlers.go b/routingkey_handlers.go index 8251970..e13a980 100644 --- a/routingkey_handlers.go +++ b/routingkey_handlers.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/routingkey_handlers_test.go b/routingkey_handlers_test.go index c5f891f..52f4fe2 100644 --- a/routingkey_handlers_test.go +++ b/routingkey_handlers_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup.go b/setup.go index 06b57c6..29a24e0 100644 --- a/setup.go +++ b/setup.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup_consumer.go b/setup_consumer.go index 60d86b4..b04507a 100644 --- a/setup_consumer.go +++ b/setup_consumer.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup_consumer_test.go b/setup_consumer_test.go index 80f9429..2d3da1d 100644 --- a/setup_consumer_test.go +++ b/setup_consumer_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup_publisher.go b/setup_publisher.go index fa1bf7d..ed5aadb 100644 --- a/setup_publisher.go +++ b/setup_publisher.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup_publisher_test.go b/setup_publisher_test.go index 9f9c4f6..99b1f0c 100644 --- a/setup_publisher_test.go +++ b/setup_publisher_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/setup_test.go b/setup_test.go index 5b86008..34d29a2 100644 --- a/setup_test.go +++ b/setup_test.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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/tracing.go b/tracing.go index 65340a2..852d31e 100644 --- a/tracing.go +++ b/tracing.go @@ -1,6 +1,6 @@ // MIT License // -// Copyright (c) 2024 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