11package com .babelqueue .sqs ;
22
33import com .babelqueue .Envelope ;
4+ import java .util .ArrayList ;
45import java .util .LinkedHashMap ;
6+ import java .util .List ;
57import java .util .Map ;
68import software .amazon .awssdk .services .sqs .model .MessageAttributeValue ;
79
810/**
911 * Projects the envelope's contract fields onto native SQS {@code MessageAttributes} —
1012 * a redundant, routable view of the body (the body stays authoritative). Contract §3.2.
13+ *
14+ * <p>It also carries out-of-band transport headers (e.g. the W3C {@code traceparent},
15+ * ADR-0028) <b>beside</b> the contract {@code bq-*} attributes on the same channel
16+ * {@code bq-trace-id} already rides — never inside the frozen envelope (GR-1). The
17+ * contract projection always wins a key collision, and the merge stops at the SQS
18+ * 10-attribute ceiling so unbounded out-of-band headers can never crowd out the
19+ * contract attributes.
1120 */
1221final class SqsAttributes {
1322
23+ /** The SQS per-message cap on user {@code MessageAttributes}. */
24+ static final int MAX_ATTRIBUTES = 10 ;
25+
1426 private SqsAttributes () {}
1527
1628 static Map <String , MessageAttributeValue > project (Envelope envelope ) {
@@ -26,6 +38,61 @@ static Map<String, MessageAttributeValue> project(Envelope envelope) {
2638 return attrs ;
2739 }
2840
41+ /**
42+ * Projects the contract attributes and overlays the out-of-band string {@code headers}
43+ * beside them — for {@link SqsPublisher#publishWithHeaders}. A blank key or value is
44+ * skipped; a contract {@code bq-*} key already present always wins (the header is
45+ * dropped, never clobbering it); and once the message reaches {@link #MAX_ATTRIBUTES}
46+ * no further header is added. Keys are merged in sorted order so the bounded subset is
47+ * deterministic. With a {@code null}/empty map this is byte-identical to {@link #project}.
48+ */
49+ static Map <String , MessageAttributeValue > projectWithHeaders (
50+ Envelope envelope , Map <String , String > headers ) {
51+ Map <String , MessageAttributeValue > attrs = project (envelope );
52+ if (headers == null || headers .isEmpty ()) {
53+ return attrs ;
54+ }
55+ List <String > keys = new ArrayList <>(headers .keySet ());
56+ keys .sort (String ::compareTo );
57+ for (String key : keys ) {
58+ if (attrs .size () >= MAX_ATTRIBUTES ) {
59+ break ; // respect the SQS 10-attribute ceiling
60+ }
61+ if (key == null || key .isEmpty () || attrs .containsKey (key )) {
62+ continue ; // contract attribute wins a collision; skip blank keys
63+ }
64+ String value = headers .get (key );
65+ if (value == null || value .isEmpty ()) {
66+ continue ;
67+ }
68+ attrs .put (key , MessageAttributeValue .builder ().dataType ("String" ).stringValue (value ).build ());
69+ }
70+ return attrs ;
71+ }
72+
73+ /**
74+ * Surfaces a delivered message's {@code MessageAttributes} as a flat
75+ * {@code Map<String, String>} (the consume-side counterpart of
76+ * {@link #projectWithHeaders}), reading each attribute's {@code stringValue}. Both the
77+ * contract {@code bq-*} attributes and out-of-band headers (e.g. {@code traceparent})
78+ * surface; the consumer picks the keys it needs. Blank values are dropped; an empty or
79+ * {@code null} input yields an empty map.
80+ */
81+ static Map <String , String > extract (Map <String , MessageAttributeValue > attrs ) {
82+ Map <String , String > out = new LinkedHashMap <>();
83+ if (attrs == null ) {
84+ return out ;
85+ }
86+ for (Map .Entry <String , MessageAttributeValue > e : attrs .entrySet ()) {
87+ MessageAttributeValue v = e .getValue ();
88+ String value = v == null ? null : v .stringValue ();
89+ if (value != null && !value .isEmpty ()) {
90+ out .put (e .getKey (), value );
91+ }
92+ }
93+ return out ;
94+ }
95+
2996 private static void putString (Map <String , MessageAttributeValue > attrs , String key , String value ) {
3097 if (value != null && !value .isEmpty ()) {
3198 attrs .put (key , MessageAttributeValue .builder ().dataType ("String" ).stringValue (value ).build ());
0 commit comments