66// consumer can route on properties.type without parsing the body. Consuming uses
77// basic.get + manual ack (at-least-once), matching the PHP RabbitMQ driver.
88//
9+ // It also implements the optional [babelqueue.HeaderPublisher] capability: out-of-band
10+ // transport headers (e.g. a W3C traceparent for cross-hop span linkage, ADR-0028, or
11+ // the bq-replay-bypass marker, ADR-0027) ride in the AMQP message headers
12+ // (amqp091.Table) beside the frozen envelope — never in it (GR-1) — and are surfaced
13+ // back to the consumer on [babelqueue.ReceivedMessage.Headers].
14+ //
915// tr := amqp.New("amqp://guest:guest@localhost:5672/")
1016// app := babelqueue.NewApp(tr, babelqueue.WithDefaultQueue("orders"))
1117//
@@ -14,6 +20,7 @@ package amqp
1420
1521import (
1622 "context"
23+ "fmt"
1724 "sync"
1825 "time"
1926
@@ -74,6 +81,20 @@ func (t *Transport) declare(ch *amqp091.Channel, queue string) error {
7481// Publish declares the durable queue and publishes body with persistent delivery
7582// and the contract AMQP properties.
7683func (t * Transport ) Publish (ctx context.Context , queue , body string ) error {
84+ return t .publish (ctx , queue , body , nil )
85+ }
86+
87+ // PublishWithHeaders publishes body together with out-of-band transport headers
88+ // ([babelqueue.HeaderPublisher]). The headers ride in the AMQP message headers
89+ // (amqp091.Table) beside the frozen envelope (GR-1) — e.g. a W3C traceparent for
90+ // cross-hop span linkage (ADR-0028). They are merged on top of the contract x-*
91+ // headers without clobbering them (a contract header always wins a key collision),
92+ // and empty/blank keys are skipped. A nil/empty map behaves exactly like [Transport.Publish].
93+ func (t * Transport ) PublishWithHeaders (ctx context.Context , queue , body string , headers map [string ]string ) error {
94+ return t .publish (ctx , queue , body , headers )
95+ }
96+
97+ func (t * Transport ) publish (ctx context.Context , queue , body string , headers map [string ]string ) error {
7798 t .mu .Lock ()
7899 defer t .mu .Unlock ()
79100 ch , err := t .ensure ()
@@ -83,33 +104,84 @@ func (t *Transport) Publish(ctx context.Context, queue, body string) error {
83104 if err := t .declare (ch , queue ); err != nil {
84105 return err
85106 }
86- return ch .PublishWithContext (ctx , "" , queue , false , false , t .publishing (body ))
107+ return ch .PublishWithContext (ctx , "" , queue , false , false , t .publishing (body , headers ))
87108}
88109
89- func (t * Transport ) publishing (body string ) amqp091.Publishing {
110+ // publishing builds the AMQP message for body. extra carries optional out-of-band
111+ // transport headers that are merged into the message header table without overwriting
112+ // the contract x-* headers (the contract wins a key collision).
113+ func (t * Transport ) publishing (body string , extra map [string ]string ) amqp091.Publishing {
90114 pub := amqp091.Publishing {
91115 ContentType : "application/json" ,
92116 ContentEncoding : "utf-8" ,
93117 DeliveryMode : amqp091 .Persistent ,
94118 AppId : "babelqueue" ,
95119 Body : []byte (body ),
96120 }
121+ headers := amqp091.Table {}
122+ mergeHeaders (headers , extra )
97123 if env , err := babelqueue .Decode ([]byte (body )); err == nil {
98124 pub .Type = env .Job
99125 pub .CorrelationId = env .TraceID
100126 pub .MessageId = env .Meta .ID
101- headers := amqp091.Table {"x-attempts" : env .Attempts }
127+ // Contract x-* headers are written last so they win any key collision with an
128+ // out-of-band header — the cross-language projection must never be clobbered.
129+ headers ["x-attempts" ] = env .Attempts
102130 if env .Meta .SchemaVersion != 0 {
103131 headers ["x-schema-version" ] = env .Meta .SchemaVersion
104132 }
105133 if env .Meta .Lang != "" {
106134 headers ["x-source-lang" ] = env .Meta .Lang
107135 }
136+ }
137+ if len (headers ) > 0 {
108138 pub .Headers = headers
109139 }
110140 return pub
111141}
112142
143+ // mergeHeaders writes the out-of-band string headers into table as AMQP values,
144+ // skipping empty keys and values. It never removes or overwrites a key already in
145+ // table, so callers can seed contract headers afterwards and keep them authoritative.
146+ func mergeHeaders (table amqp091.Table , headers map [string ]string ) {
147+ for k , v := range headers {
148+ if k == "" || v == "" {
149+ continue
150+ }
151+ if _ , exists := table [k ]; exists {
152+ continue
153+ }
154+ table [k ] = v
155+ }
156+ }
157+
158+ // headersFromTable maps an inbound AMQP header table to a flat map[string]string,
159+ // stringifying values defensively (AMQP headers are typed: strings, ints, bytes…).
160+ // It returns nil when no headers are present so a header-less delivery stays
161+ // header-less on [babelqueue.ReceivedMessage.Headers].
162+ func headersFromTable (table amqp091.Table ) map [string ]string {
163+ if len (table ) == 0 {
164+ return nil
165+ }
166+ out := make (map [string ]string , len (table ))
167+ for k , v := range table {
168+ switch val := v .(type ) {
169+ case string :
170+ out [k ] = val
171+ case []byte :
172+ out [k ] = string (val )
173+ case nil :
174+ // skip nil-valued headers
175+ default :
176+ out [k ] = fmt .Sprintf ("%v" , val )
177+ }
178+ }
179+ if len (out ) == 0 {
180+ return nil
181+ }
182+ return out
183+ }
184+
113185// Pop reserves the next message with basic.get (manual ack). When the queue is
114186// empty it waits up to timeout (heartbeat-safe) and returns (nil, nil).
115187func (t * Transport ) Pop (ctx context.Context , queue string , timeout time.Duration ) (* babelqueue.ReceivedMessage , error ) {
@@ -139,7 +211,12 @@ func (t *Transport) Pop(ctx context.Context, queue string, timeout time.Duration
139211 }
140212 return nil , nil
141213 }
142- return & babelqueue.ReceivedMessage {Body : string (delivery .Body ), Queue : queue , Handle : delivery }, nil
214+ return & babelqueue.ReceivedMessage {
215+ Body : string (delivery .Body ),
216+ Queue : queue ,
217+ Handle : delivery ,
218+ Headers : headersFromTable (delivery .Headers ),
219+ }, nil
143220}
144221
145222// Ack acknowledges the reserved delivery.
@@ -164,4 +241,7 @@ func (t *Transport) Close() error {
164241 return nil
165242}
166243
167- var _ babelqueue.Transport = (* Transport )(nil )
244+ var (
245+ _ babelqueue.Transport = (* Transport )(nil )
246+ _ babelqueue.HeaderPublisher = (* Transport )(nil )
247+ )
0 commit comments