Skip to content

Commit 421e13b

Browse files
authored
feat(cre): adds MultiHeaders support to gateway (#1822)
* chore: adds MultiHeaders to OutboundHTTPReq/Res * chore(gateway): adds tests for fields * chore(gateway): deprecates Hash method * fix(gateway): hashes all headers related fields * refactor(gateway): uses slices and maps pkgs * fix: compatability -> compatibility
1 parent ec982b4 commit 421e13b

3 files changed

Lines changed: 178 additions & 24 deletions

File tree

pkg/capabilities/errors/error_serialization.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func DeserializeErrorFromString(errorMsg string) Error {
1515
parts := strings.SplitN(errorMsg, errorMessageSeparator, 4)
1616

1717
if len(parts) < 4 {
18-
// To maintain backwards compatability with messages from remote nodes on an older code version, create an error
18+
// To maintain backwards compatibility with messages from remote nodes on an older code version, create an error
1919
// with the full message and default to private system error with an unknown error code.
2020
return NewError(errors.New(errorMsg), VisibilityPrivate, OriginSystem, Unknown)
2121
}

pkg/types/gateway/action.go

Lines changed: 48 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ package gateway
33
import (
44
"crypto/sha256"
55
"encoding/hex"
6-
"sort"
6+
"hash"
7+
"maps"
8+
"slices"
79
"strconv"
810
"time"
911
)
@@ -23,21 +25,26 @@ type CacheSettings struct {
2325

2426
// OutboundHTTPRequest represents an HTTP request to be sent from workflow node to the gateway.
2527
type OutboundHTTPRequest struct {
26-
URL string `json:"url"` // URL to query, only http and https protocols are supported.
27-
Method string `json:"method,omitempty"` // HTTP verb, defaults to GET.
28-
Headers map[string]string `json:"headers,omitempty"` // HTTP headers, defaults to empty.
29-
Body []byte `json:"body,omitempty"` // HTTP request body
30-
TimeoutMs uint32 `json:"timeoutMs,omitempty"` // Timeout in milliseconds
31-
CacheSettings CacheSettings `json:"cacheSettings"` // Best-effort cache control for the request
28+
URL string `json:"url"` // URL to query, only http and https protocols are supported.
29+
Method string `json:"method,omitempty"` // HTTP verb, defaults to GET.
30+
31+
// Deprecated: Use MultiHeaders instead. Headers is a comma joined string of all values for a given header for backwards
32+
// compatibility.
33+
Headers map[string]string `json:"headers,omitempty"` // HTTP headers, defaults to empty.
34+
MultiHeaders map[string][]string `json:"multiHeaders,omitempty"` // HTTP headers with all values preserved
35+
Body []byte `json:"body,omitempty"` // HTTP request body
36+
TimeoutMs uint32 `json:"timeoutMs,omitempty"` // Timeout in milliseconds
37+
CacheSettings CacheSettings `json:"cacheSettings"` // Best-effort cache control for the request
3238

3339
// Maximum number of bytes to read from the response body. If the gateway max response size is smaller than this value, the gateway max response size will be used.
3440
MaxResponseBytes uint32 `json:"maxBytes,omitempty"`
3541
WorkflowID string `json:"workflowId"`
3642
WorkflowOwner string `json:"workflowOwner"`
3743
}
3844

39-
// Hash generates a hash of the request for caching purposes.
40-
// WorkflowID is not included in the hash because cached responses can be used across workflows
45+
// Hash generates a deterministic hash of the request for caching purposes.
46+
// Every field that is present is included; map and slice order is normalized by sorting.
47+
// WorkflowID is not included so cached responses can be shared across workflows.
4148
func (req OutboundHTTPRequest) Hash() string {
4249
s := sha256.New()
4350
sep := []byte("/")
@@ -51,22 +58,37 @@ func (req OutboundHTTPRequest) Hash() string {
5158
s.Write(req.Body)
5259
s.Write(sep)
5360

54-
// To ensure deterministic order, iterate headers in sorted order
55-
keys := make([]string, 0, len(req.Headers))
56-
for k := range req.Headers {
57-
keys = append(keys, k)
58-
}
59-
sort.Strings(keys)
61+
writeHeadersToHash(s, sep, req.Headers, req.MultiHeaders)
62+
63+
s.Write([]byte(strconv.FormatUint(uint64(req.MaxResponseBytes), 10)))
64+
65+
return hex.EncodeToString(s.Sum(nil))
66+
}
67+
68+
// writeHeadersToHash writes all present header data in a deterministic order:
69+
// Headers (sorted keys) then MultiHeaders (sorted keys, sorted values per key).
70+
func writeHeadersToHash(s hash.Hash, sep []byte, headers map[string]string, multiHeaders map[string][]string) {
71+
keys := slices.Collect(maps.Keys(headers))
72+
slices.Sort(keys)
6073
for _, key := range keys {
6174
s.Write([]byte(key))
6275
s.Write(sep)
63-
s.Write([]byte(req.Headers[key]))
76+
s.Write([]byte(headers[key]))
6477
s.Write(sep)
6578
}
66-
67-
s.Write([]byte(strconv.FormatUint(uint64(req.MaxResponseBytes), 10)))
68-
69-
return hex.EncodeToString(s.Sum(nil))
79+
keys = slices.Collect(maps.Keys(multiHeaders))
80+
slices.Sort(keys)
81+
for _, key := range keys {
82+
vals := multiHeaders[key]
83+
valsCopy := slices.Clone(vals)
84+
slices.Sort(valsCopy)
85+
s.Write([]byte(key))
86+
s.Write(sep)
87+
for _, v := range valsCopy {
88+
s.Write([]byte(v))
89+
s.Write(sep)
90+
}
91+
}
7092
}
7193

7294
// OutboundHTTPResponse represents the response from gateway to workflow node.
@@ -88,7 +110,10 @@ type OutboundHTTPResponse struct {
88110
// This field is only populated when the request successfully reaches the customer's endpoint and the response is received.
89111
StatusCode int `json:"statusCode,omitempty"`
90112

91-
Headers map[string]string `json:"headers,omitempty"` // HTTP headers returned by the customer's endpoint
92-
Body []byte `json:"body,omitempty"` // HTTP response body returned by the customer's endpoint
93-
ExternalEndpointLatency time.Duration `json:"externalEndpointLatency,omitempty"` // Time taken by the customer's endpoint to respond
113+
// Deprecated: Use MultiHeaders instead. Headers is a comma joined string of all values for a given header for backwards
114+
// compatibility.
115+
Headers map[string]string `json:"headers,omitempty"` // HTTP headers returned by the customer's endpoint
116+
MultiHeaders map[string][]string `json:"multiHeaders,omitempty"` // HTTP headers with all values preserved
117+
Body []byte `json:"body,omitempty"` // HTTP response body returned by the customer's endpoint
118+
ExternalEndpointLatency time.Duration `json:"externalEndpointLatency,omitempty"` // Time taken by the customer's endpoint to respond
94119
}

pkg/types/gateway/action_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package gateway
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/require"
7+
)
8+
9+
func TestOutboundHTTPRequest_Hash(t *testing.T) {
10+
baseWithHeaders := OutboundHTTPRequest{
11+
Method: "GET",
12+
URL: "https://example.com/",
13+
WorkflowOwner: "owner",
14+
Headers: map[string]string{"A": "1"},
15+
}
16+
baseWithMultiHeaders := OutboundHTTPRequest{
17+
Method: "GET",
18+
URL: "https://example.com/",
19+
WorkflowOwner: "owner",
20+
MultiHeaders: map[string][]string{"A": {"1", "2"}},
21+
}
22+
23+
tests := []struct {
24+
name string
25+
reqA OutboundHTTPRequest
26+
reqB OutboundHTTPRequest
27+
sameHash bool
28+
}{
29+
{
30+
name: "Headers only same content different map order",
31+
reqA: OutboundHTTPRequest{
32+
Method: "GET",
33+
URL: "https://example.com/api",
34+
WorkflowOwner: "owner-1",
35+
Body: []byte(`{"a":1}`),
36+
Headers: map[string]string{"Accept": "application/json", "Content-Type": "application/json", "X-Request-Id": "req-123"},
37+
},
38+
reqB: OutboundHTTPRequest{
39+
Method: "GET",
40+
URL: "https://example.com/api",
41+
WorkflowOwner: "owner-1",
42+
Body: []byte(`{"a":1}`),
43+
Headers: map[string]string{"X-Request-Id": "req-123", "Content-Type": "application/json", "Accept": "application/json"},
44+
},
45+
sameHash: true,
46+
},
47+
{
48+
name: "MultiHeaders only same content different key and value order",
49+
reqA: OutboundHTTPRequest{
50+
Method: "GET",
51+
URL: "https://example.com/api",
52+
WorkflowOwner: "owner-1",
53+
Body: []byte(`{"a":1}`),
54+
MultiHeaders: map[string][]string{"Accept": {"application/json"}, "Content-Type": {"application/json"}, "Set-Cookie": {"s1=abc", "s2=def"}},
55+
},
56+
reqB: OutboundHTTPRequest{
57+
Method: "GET",
58+
URL: "https://example.com/api",
59+
WorkflowOwner: "owner-1",
60+
Body: []byte(`{"a":1}`),
61+
MultiHeaders: map[string][]string{"Set-Cookie": {"s2=def", "s1=abc"}, "Content-Type": {"application/json"}, "Accept": {"application/json"}},
62+
},
63+
sameHash: true,
64+
},
65+
{
66+
// Headers is the comma-joined version of MultiHeaders; same data, different value order in MultiHeaders.
67+
name: "Headers and MultiHeaders both present same content",
68+
reqA: OutboundHTTPRequest{
69+
Method: "GET",
70+
URL: "https://example.com/api",
71+
WorkflowOwner: "owner-1",
72+
Body: []byte(`{"a":1}`),
73+
Headers: map[string]string{"X": "1", "Y": "a,b"},
74+
MultiHeaders: map[string][]string{"X": {"1"}, "Y": {"a", "b"}},
75+
},
76+
reqB: OutboundHTTPRequest{
77+
Method: "GET",
78+
URL: "https://example.com/api",
79+
WorkflowOwner: "owner-1",
80+
Body: []byte(`{"a":1}`),
81+
Headers: map[string]string{"X": "1", "Y": "a,b"},
82+
MultiHeaders: map[string][]string{"X": {"1"}, "Y": {"b", "a"}},
83+
},
84+
sameHash: true,
85+
},
86+
{
87+
name: "Same MultiHeaders values different slice order same hash",
88+
reqA: baseWithMultiHeaders,
89+
reqB: baseWithMultiHeaders,
90+
sameHash: true,
91+
},
92+
{
93+
name: "Different Headers value yields different hash",
94+
reqA: baseWithHeaders,
95+
reqB: OutboundHTTPRequest{
96+
Method: "GET",
97+
URL: "https://example.com/",
98+
WorkflowOwner: "owner",
99+
Headers: map[string]string{"A": "2"},
100+
},
101+
sameHash: false,
102+
},
103+
{
104+
name: "Different MultiHeaders content yields different hash",
105+
reqA: baseWithMultiHeaders,
106+
reqB: OutboundHTTPRequest{
107+
Method: "GET",
108+
URL: "https://example.com/",
109+
WorkflowOwner: "owner",
110+
MultiHeaders: map[string][]string{"A": {"1", "3"}},
111+
},
112+
sameHash: false,
113+
},
114+
}
115+
116+
for _, tt := range tests {
117+
t.Run(tt.name, func(t *testing.T) {
118+
hashA := tt.reqA.Hash()
119+
hashB := tt.reqB.Hash()
120+
require.NotEmpty(t, hashA)
121+
require.NotEmpty(t, hashB)
122+
if tt.sameHash {
123+
require.Equal(t, hashA, hashB)
124+
} else {
125+
require.NotEqual(t, hashA, hashB)
126+
}
127+
})
128+
}
129+
}

0 commit comments

Comments
 (0)