Skip to content

Commit bc42dc7

Browse files
authored
Merge pull request #189 from hookdeck/next
v1.5
2 parents 876e9fe + c330a68 commit bc42dc7

14 files changed

Lines changed: 537 additions & 38 deletions

File tree

go.mod

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ go 1.24.9
44

55
require (
66
github.com/AlecAivazis/survey/v2 v2.3.7
7-
github.com/BurntSushi/toml v1.5.0
7+
github.com/BurntSushi/toml v1.6.0
88
github.com/briandowns/spinner v1.23.2
99
github.com/charmbracelet/bubbles v0.21.0
1010
github.com/charmbracelet/bubbletea v1.3.10
@@ -16,14 +16,14 @@ require (
1616
github.com/logrusorgru/aurora v2.0.3+incompatible
1717
github.com/mitchellh/go-homedir v1.1.0
1818
github.com/sirupsen/logrus v1.9.3
19-
github.com/spf13/cobra v1.10.1
19+
github.com/spf13/cobra v1.10.2
2020
github.com/spf13/pflag v1.0.10
2121
github.com/spf13/viper v1.21.0
2222
github.com/stretchr/testify v1.11.1
2323
github.com/tidwall/pretty v1.2.1
2424
github.com/x-cray/logrus-prefixed-formatter v0.5.2
25-
golang.org/x/sys v0.38.0
26-
golang.org/x/term v0.37.0
25+
golang.org/x/sys v0.40.0
26+
golang.org/x/term v0.38.0
2727
)
2828

2929
require (

go.sum

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
22
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
3-
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
4-
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3+
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
4+
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
55
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
66
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
77
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -135,8 +135,8 @@ github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
135135
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
136136
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
137137
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
138-
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
139-
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
138+
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
139+
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
140140
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
141141
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
142142
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
@@ -193,12 +193,12 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
193193
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
194194
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
195195
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
196-
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
197-
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
196+
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
197+
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
198198
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
199199
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
200-
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
201-
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
200+
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
201+
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
202202
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
203203
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
204204
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=

pkg/cmd/listen.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ Destination CLI path will be "/". To set the CLI path, use the "--path" flag.`,
153153
lc.cmd.Flags().StringVar(&lc.path, "path", "", "Sets the path to which events are forwarded e.g., /webhooks or /api/stripe")
154154
lc.cmd.Flags().IntVar(&lc.maxConnections, "max-connections", 50, "Maximum concurrent connections to local endpoint (default: 50, increase for high-volume testing)")
155155

156-
lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (only fatal errors)")
156+
lc.cmd.Flags().StringVar(&lc.output, "output", "interactive", "Output mode: interactive (full UI), compact (simple logs), quiet (errors and warnings only)")
157157

158158
lc.cmd.Flags().StringVar(&lc.filterBody, "filter-body", "", "Filter events by request body using Hookdeck filter syntax (JSON)")
159159
lc.cmd.Flags().StringVar(&lc.filterHeaders, "filter-headers", "", "Filter events by request headers using Hookdeck filter syntax (JSON)")

pkg/listen/healthcheck.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package listen
2+
3+
import (
4+
"net/url"
5+
"time"
6+
7+
"github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck"
8+
)
9+
10+
// Re-export types and constants from healthcheck subpackage for convenience
11+
type ServerHealthStatus = healthcheck.ServerHealthStatus
12+
type HealthCheckResult = healthcheck.HealthCheckResult
13+
14+
const (
15+
HealthHealthy = healthcheck.HealthHealthy
16+
HealthUnreachable = healthcheck.HealthUnreachable
17+
)
18+
19+
// CheckServerHealth performs a TCP connection check to the target URL
20+
// This is a wrapper around the healthcheck package function for backward compatibility
21+
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
22+
return healthcheck.CheckServerHealth(targetURL, timeout)
23+
}
24+
25+
// FormatHealthMessage creates a user-friendly health status message
26+
// This is a wrapper around the healthcheck package function for backward compatibility
27+
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
28+
return healthcheck.FormatHealthMessage(result, targetURL)
29+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
package healthcheck
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/url"
7+
"os"
8+
"time"
9+
10+
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
11+
)
12+
13+
// ServerHealthStatus represents the health status of the target server
14+
type ServerHealthStatus int
15+
16+
const (
17+
HealthHealthy ServerHealthStatus = iota // TCP connection successful
18+
HealthUnreachable // Connection refused or timeout
19+
)
20+
21+
// HealthCheckResult contains the result of a health check
22+
type HealthCheckResult struct {
23+
Status ServerHealthStatus
24+
Healthy bool
25+
Error error
26+
Timestamp time.Time
27+
Duration time.Duration
28+
}
29+
30+
// CheckServerHealth performs a TCP connection check to verify a server is listening.
31+
// The timeout parameter should be appropriate for the deployment context:
32+
// - Local development: 3s is typically sufficient
33+
// - Production/edge: May require longer timeouts due to network conditions
34+
func CheckServerHealth(targetURL *url.URL, timeout time.Duration) HealthCheckResult {
35+
start := time.Now()
36+
37+
host := targetURL.Hostname()
38+
port := targetURL.Port()
39+
40+
// Default ports if not specified
41+
if port == "" {
42+
if targetURL.Scheme == "https" {
43+
port = "443"
44+
} else {
45+
port = "80"
46+
}
47+
}
48+
49+
address := net.JoinHostPort(host, port)
50+
51+
conn, err := net.DialTimeout("tcp", address, timeout)
52+
duration := time.Since(start)
53+
54+
result := HealthCheckResult{
55+
Timestamp: start,
56+
Duration: duration,
57+
}
58+
59+
if err != nil {
60+
result.Healthy = false
61+
result.Error = err
62+
result.Status = HealthUnreachable
63+
return result
64+
}
65+
66+
// Successfully connected - server is healthy
67+
conn.Close()
68+
result.Healthy = true
69+
result.Status = HealthHealthy
70+
return result
71+
}
72+
73+
// FormatHealthMessage creates a user-friendly health status message
74+
func FormatHealthMessage(result HealthCheckResult, targetURL *url.URL) string {
75+
if result.Healthy {
76+
return fmt.Sprintf("→ Local server is reachable at %s", targetURL.String())
77+
}
78+
79+
color := ansi.Color(os.Stdout)
80+
errorMessage := "unknown error"
81+
if result.Error != nil {
82+
errorMessage = result.Error.Error()
83+
}
84+
return fmt.Sprintf("%s Cannot connect to local server at %s\n %s\n The server may not be running. Events will fail until the server starts.",
85+
color.Yellow("● Warning:"),
86+
targetURL.String(),
87+
errorMessage)
88+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package healthcheck
2+
3+
import (
4+
"fmt"
5+
"net"
6+
"net/http"
7+
"net/http/httptest"
8+
"net/url"
9+
"strings"
10+
"testing"
11+
"time"
12+
)
13+
14+
func TestCheckServerHealth_HealthyServer(t *testing.T) {
15+
// Start a test HTTP server
16+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
17+
w.WriteHeader(http.StatusOK)
18+
}))
19+
defer server.Close()
20+
21+
// Parse server URL
22+
serverURL, err := url.Parse(server.URL)
23+
if err != nil {
24+
t.Fatalf("Failed to parse server URL: %v", err)
25+
}
26+
27+
// Perform health check
28+
result := CheckServerHealth(serverURL, 3*time.Second)
29+
30+
// Verify result
31+
if !result.Healthy {
32+
t.Errorf("Expected server to be healthy, got unhealthy")
33+
}
34+
if result.Status != HealthHealthy {
35+
t.Errorf("Expected status HealthHealthy, got %v", result.Status)
36+
}
37+
if result.Error != nil {
38+
t.Errorf("Expected no error, got: %v", result.Error)
39+
}
40+
if result.Duration <= 0 {
41+
t.Errorf("Expected positive duration, got: %v", result.Duration)
42+
}
43+
}
44+
45+
func TestCheckServerHealth_UnreachableServer(t *testing.T) {
46+
// Use a URL that should not be listening
47+
targetURL, err := url.Parse("http://localhost:59999")
48+
if err != nil {
49+
t.Fatalf("Failed to parse URL: %v", err)
50+
}
51+
52+
// Perform health check
53+
result := CheckServerHealth(targetURL, 1*time.Second)
54+
55+
// Verify result
56+
if result.Healthy {
57+
t.Errorf("Expected server to be unhealthy, got healthy")
58+
}
59+
if result.Status != HealthUnreachable {
60+
t.Errorf("Expected status HealthUnreachable, got %v", result.Status)
61+
}
62+
if result.Error == nil {
63+
t.Errorf("Expected error, got nil")
64+
}
65+
}
66+
67+
func TestCheckServerHealth_DefaultPorts(t *testing.T) {
68+
testCases := []struct {
69+
name string
70+
urlString string
71+
expectedPort string
72+
}{
73+
{
74+
name: "HTTP default port",
75+
urlString: "http://localhost",
76+
expectedPort: "80",
77+
},
78+
{
79+
name: "HTTPS default port",
80+
urlString: "https://localhost",
81+
expectedPort: "443",
82+
},
83+
{
84+
name: "Explicit port",
85+
urlString: "http://localhost:8080",
86+
expectedPort: "8080",
87+
},
88+
}
89+
90+
for _, tc := range testCases {
91+
t.Run(tc.name, func(t *testing.T) {
92+
targetURL, err := url.Parse(tc.urlString)
93+
if err != nil {
94+
t.Fatalf("Failed to parse URL: %v", err)
95+
}
96+
97+
// Start a listener on the expected port to verify we're checking the right one
98+
listener, err := net.Listen("tcp", "localhost:"+tc.expectedPort)
99+
if err != nil {
100+
t.Skipf("Cannot bind to port %s: %v", tc.expectedPort, err)
101+
}
102+
defer listener.Close()
103+
104+
// Perform health check
105+
result := CheckServerHealth(targetURL, 1*time.Second)
106+
107+
// Should be healthy since we have a listener
108+
if !result.Healthy {
109+
t.Errorf("Expected server to be healthy on port %s, got unhealthy: %v", tc.expectedPort, result.Error)
110+
}
111+
})
112+
}
113+
}
114+
115+
func TestFormatHealthMessage_Healthy(t *testing.T) {
116+
targetURL, _ := url.Parse("http://localhost:3000")
117+
result := HealthCheckResult{
118+
Status: HealthHealthy,
119+
Healthy: true,
120+
}
121+
122+
msg := FormatHealthMessage(result, targetURL)
123+
124+
if len(msg) == 0 {
125+
t.Errorf("Expected non-empty message")
126+
}
127+
if !strings.Contains(msg, "→") {
128+
t.Errorf("Expected message to contain →")
129+
}
130+
if !strings.Contains(msg, "Local server is reachable") {
131+
t.Errorf("Expected message to contain 'Local server is reachable'")
132+
}
133+
}
134+
135+
func TestFormatHealthMessage_Unhealthy(t *testing.T) {
136+
targetURL, _ := url.Parse("http://localhost:3000")
137+
result := HealthCheckResult{
138+
Status: HealthUnreachable,
139+
Healthy: false,
140+
Error: net.ErrClosed,
141+
}
142+
143+
msg := FormatHealthMessage(result, targetURL)
144+
145+
if len(msg) == 0 {
146+
t.Errorf("Expected non-empty message")
147+
}
148+
// Should contain warning indicator
149+
if !strings.Contains(msg, "●") {
150+
t.Errorf("Expected message to contain ●")
151+
}
152+
if !strings.Contains(msg, "Warning") {
153+
t.Errorf("Expected message to contain 'Warning'")
154+
}
155+
}
156+
157+
func TestFormatHealthMessage_NilError(t *testing.T) {
158+
targetURL, _ := url.Parse("http://localhost:3000")
159+
result := HealthCheckResult{
160+
Status: HealthUnreachable,
161+
Healthy: false,
162+
Error: nil, // Nil error should not cause panic
163+
}
164+
165+
msg := FormatHealthMessage(result, targetURL)
166+
167+
if len(msg) == 0 {
168+
t.Errorf("Expected non-empty message")
169+
}
170+
if !strings.Contains(msg, "unknown error") {
171+
t.Errorf("Expected message to contain 'unknown error' when error is nil")
172+
}
173+
}
174+
175+
func TestCheckServerHealth_PortInURL(t *testing.T) {
176+
// Create a server on a non-standard port
177+
listener, err := net.Listen("tcp", "localhost:0")
178+
if err != nil {
179+
t.Fatalf("Failed to create listener: %v", err)
180+
}
181+
defer listener.Close()
182+
183+
// Get the actual port assigned by the OS
184+
addr := listener.Addr().(*net.TCPAddr)
185+
targetURL, _ := url.Parse(fmt.Sprintf("http://localhost:%d/path", addr.Port))
186+
187+
// Perform health check
188+
result := CheckServerHealth(targetURL, 3*time.Second)
189+
190+
// Verify that the health check succeeded
191+
// This confirms that when a port is already in the URL, we don't append
192+
// a default port (which would cause localhost:8080 to become localhost:8080:80)
193+
if !result.Healthy {
194+
t.Errorf("Expected healthy=true for server with port in URL, got false: %v", result.Error)
195+
}
196+
if result.Error != nil {
197+
t.Errorf("Expected no error for server with port in URL, got: %v", result.Error)
198+
}
199+
}

0 commit comments

Comments
 (0)