Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions pkg/listen/forwardurl/forwardurl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// Package forwardurl builds the URL that `hookdeck listen` forwards an event
// to. The request and every place that prints the URL share it, so what the
// CLI shows is what the CLI sent.
package forwardurl

import (
"net/url"
"strings"
)

// JoinPath appends the event's path to the forwarding URL's path with a single
// "/" between them. A trailing "/" on the forwarding URL is dropped only when
// the event path starts with one, and the event path itself is left as is: a
// receiver may route on its own trailing slash, which is why path.Join is not
// used.
func JoinPath(base, eventPath string) string {
if strings.HasPrefix(eventPath, "/") {
base = strings.TrimSuffix(base, "/")
}
return base + eventPath
}

// Build returns the full URL for an event: the forwarding URL's scheme, host
// and path, then the event's path.
func Build(target *url.URL, eventPath string) string {
return target.Scheme + "://" + target.Host + JoinPath(target.Path, eventPath)
}
33 changes: 33 additions & 0 deletions pkg/listen/forwardurl/forwardurl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package forwardurl

import (
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestBuild(t *testing.T) {
tests := []struct {
name string
target string
eventPath string
want string
}{
{"forwarding URL with a trailing slash", "http://localhost:3000/base/", "/webhook", "http://localhost:3000/base/webhook"},
{"forwarding URL without a trailing slash", "http://localhost:3000/base", "/webhook", "http://localhost:3000/base/webhook"},
{"forwarding URL is the root", "http://localhost:3000/", "/webhook", "http://localhost:3000/webhook"},
{"forwarding URL has no path", "http://localhost:3000", "/webhook", "http://localhost:3000/webhook"},
{"event path keeps its own trailing slash", "http://localhost:3000/base/", "/webhook/", "http://localhost:3000/base/webhook/"},
{"event path is empty", "http://localhost:3000/base/", "", "http://localhost:3000/base/"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
target, err := url.Parse(tt.target)
require.NoError(t, err)
assert.Equal(t, tt.want, Build(target, tt.eventPath))
})
}
}
3 changes: 2 additions & 1 deletion pkg/listen/printer.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/hookdeck/hookdeck-cli/pkg/ansi"
"github.com/hookdeck/hookdeck-cli/pkg/config"
"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl"
"github.com/hookdeck/hookdeck-cli/pkg/listen/links"
"github.com/hookdeck/hookdeck-cli/pkg/listen/summary"
)
Expand Down Expand Up @@ -61,7 +62,7 @@ func printSourcesWithConnections(config *config.Config, projectID string, source
if cliPath != nil {
path = *cliPath
}
fullPath := targetURL.Scheme + "://" + targetURL.Host + path
fullPath := forwardurl.Build(targetURL, path)

// Get connection name from FullName (format: "source -> destination")
// Split on "->" and take the second part (destination)
Expand Down
44 changes: 44 additions & 0 deletions pkg/listen/printer_forward_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package listen

import (
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
)

// The startup banner lists "Forwards to → <URL>" for each connection. It has to
// show the URL the events will be forwarded to, including the forwarding URL's
// path, joined to the connection's path with a single "/".
func TestCompactBannerForwardsToJoinsForwardingURLAndConnectionPath(t *testing.T) {
for _, tc := range []struct {
name string
forwardTo string
cliPath string
want string
}{
{"trailing slash on the forwarding URL", "http://localhost:3030/base/", "/webhook", "Forwards to → http://localhost:3030/base/webhook"},
{"no trailing slash on the forwarding URL", "http://localhost:3030/base", "/webhook", "Forwards to → http://localhost:3030/base/webhook"},
{"forwarding URL has no path", "http://localhost:3030", "/webhook", "Forwards to → http://localhost:3030/webhook"},
{"connection path is the default root", "http://localhost:3030/base/", "/", "Forwards to → http://localhost:3030/base/"},
} {
t.Run(tc.name, func(t *testing.T) {
resetColorState(t)
cfg, sources, connections, _ := printerFixture(t, 1)
targetURL, err := url.Parse(tc.forwardTo)
require.NoError(t, err)
connections[0].Destination = &hookdeck.Destination{
ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": tc.cliPath},
}

out := capturePrinterOutput(t, func() {
printSourcesWithConnections(cfg, "tm_1", sources, connections, targetURL, "")
})

assert.Contains(t, out, tc.want)
})
}
}
62 changes: 62 additions & 0 deletions pkg/listen/proxy/forward_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package proxy

import (
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)

func TestProcessAttemptJoinsForwardingURLPathAndEventPath(t *testing.T) {
tests := []struct {
name string
forwardTo string
eventPath string
expectPath string
}{
{"forwarding URL with a trailing slash", "/base/", "/webhook", "/base/webhook"},
{"forwarding URL without a trailing slash", "/base", "/webhook", "/base/webhook"},
{"forwarding URL is the root", "/", "/webhook", "/webhook"},
{"forwarding URL has no path", "", "/webhook", "/webhook"},
{"event path keeps its own trailing slash", "/base/", "/webhook/", "/base/webhook/"},
{"event path is empty", "/base/", "", "/base/"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
received := make(chan string, 1)
local := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
received <- r.URL.Path
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(local.Close)

target, err := url.Parse(local.URL + tt.forwardTo)
require.NoError(t, err)
p := New(&Config{URL: target, NoHealthcheck: true}, nil, newRecordingRenderer())

p.processAttempt(websocket.IncomingMessage{Attempt: &websocket.Attempt{
Body: websocket.AttemptBody{
Path: tt.eventPath,
EventID: "evt_test",
AttemptId: "evt_test",
Request: websocket.AttemptRequest{Method: http.MethodPost, DataString: "{}", Headers: json.RawMessage(`{}`)},
},
}})

select {
case got := <-received:
assert.Equal(t, tt.expectPath, got)
case <-time.After(5 * time.Second):
t.Fatal("local server never received the request")
}
})
}
}
3 changes: 2 additions & 1 deletion pkg/listen/proxy/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
log "github.com/sirupsen/logrus"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl"
"github.com/hookdeck/hookdeck-cli/pkg/listen/healthcheck"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)
Expand Down Expand Up @@ -400,7 +401,7 @@ func (p *Proxy) processAttempt(msg websocket.IncomingMessage) {
"prefix": "proxy.Proxy.processAttempt",
}).Debugf("Processing webhook event")

url := p.cfg.URL.Scheme + "://" + p.cfg.URL.Host + p.cfg.URL.Path + webhookEvent.Body.Path
url := forwardurl.Build(p.cfg.URL, webhookEvent.Body.Path)

// Create request with context for timeout control
timeout := webhookEvent.Body.Request.Timeout
Expand Down
81 changes: 81 additions & 0 deletions pkg/listen/proxy/renderer_forward_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package proxy

import (
"net/url"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/listen/tui"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)

// The request is sent to a URL built in processAttempt, and each renderer
// prints the URL it believes was requested. These tests pin both renderers to
// the single-slash join, so a forwarding URL ending in "/" does not print
// "/base//webhook" for a request that went to "/base/webhook".

func forwardPathRendererConfig(t *testing.T, forwardTo string) *RendererConfig {
t.Helper()
cfg := linksRendererConfig(t, "gateway")
target, err := url.Parse(forwardTo)
require.NoError(t, err)
cfg.TargetURL = target
return cfg
}

func forwardPathAttempt(eventPath string) *websocket.Attempt {
return &websocket.Attempt{Body: websocket.AttemptBody{
Path: eventPath,
EventID: "evt_1",
AttemptId: "atm_1",
ConnectionId: "web_1",
Request: websocket.AttemptRequest{Method: "POST"},
}}
}

var forwardPathRendererCases = []struct {
name string
forwardTo string
eventPath string
want string
}{
{"trailing slash on the forwarding URL", "http://localhost:3000/base/", "/webhook", "POST http://localhost:3000/base/webhook "},
{"no trailing slash on the forwarding URL", "http://localhost:3000/base", "/webhook", "POST http://localhost:3000/base/webhook "},
{"forwarding URL is the root", "http://localhost:3000/", "/webhook", "POST http://localhost:3000/webhook "},
{"event path keeps its own trailing slash", "http://localhost:3000/base/", "/webhook/", "POST http://localhost:3000/base/webhook/ "},
}

func TestSimpleRendererJoinsForwardingURLAndEventPath(t *testing.T) {
response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond}

for _, tc := range forwardPathRendererCases {
t.Run(tc.name, func(t *testing.T) {
r := NewSimpleRenderer(forwardPathRendererConfig(t, tc.forwardTo), false)
out := captureStdout(t, func() {
r.OnEventComplete("evt_1", forwardPathAttempt(tc.eventPath), response, time.Now())
})
assert.Contains(t, out, tc.want)
})
}
}

func TestInteractiveRendererJoinsForwardingURLAndEventPath(t *testing.T) {
response := &EventResponse{StatusCode: 200, Duration: 5 * time.Millisecond}

for _, tc := range forwardPathRendererCases {
t.Run(tc.name, func(t *testing.T) {
r, sent := recordingInteractiveRenderer()
r.cfg = forwardPathRendererConfig(t, tc.forwardTo)

r.OnEventComplete("evt_1", forwardPathAttempt(tc.eventPath), response, time.Now())

require.Len(t, *sent, 1)
update, ok := (*sent)[0].(tui.UpdateEventMsg)
require.True(t, ok, "unexpected message %T", (*sent)[0])
assert.Contains(t, update.LogLine, tc.want)
})
}
}
3 changes: 2 additions & 1 deletion pkg/listen/proxy/renderer_interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
log "github.com/sirupsen/logrus"

"github.com/hookdeck/hookdeck-cli/pkg/ansi"
"github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl"
"github.com/hookdeck/hookdeck-cli/pkg/listen/tui"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)
Expand Down Expand Up @@ -152,7 +153,7 @@ func (r *InteractiveRenderer) OnEventComplete(eventID string, attempt *websocket
color.Faint(localTime),
ansi.ColorizeStatus(response.StatusCode),
attempt.Body.Request.Method,
r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path,
forwardurl.Build(r.cfg.TargetURL, attempt.Body.Path),
color.Faint(fmt.Sprintf("(%dms)", durationMs)),
color.Faint("→"),
color.Faint(displayURL),
Expand Down
3 changes: 2 additions & 1 deletion pkg/listen/proxy/renderer_simple.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
log "github.com/sirupsen/logrus"

"github.com/hookdeck/hookdeck-cli/pkg/ansi"
"github.com/hookdeck/hookdeck-cli/pkg/listen/forwardurl"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)

Expand Down Expand Up @@ -149,7 +150,7 @@ func (r *SimpleRenderer) OnEventComplete(eventID string, attempt *websocket.Atte
color.Faint(localTime),
ansi.ColorizeStatus(response.StatusCode),
attempt.Body.Request.Method,
r.cfg.TargetURL.Scheme+"://"+r.cfg.TargetURL.Host+r.cfg.TargetURL.Path+attempt.Body.Path,
forwardurl.Build(r.cfg.TargetURL, attempt.Body.Path),
color.Faint(fmt.Sprintf("(%dms)", durationMs)),
color.Faint("→"),
color.Faint(displayURL),
Expand Down
77 changes: 77 additions & 0 deletions pkg/listen/tui/forward_path_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package tui

import (
"net/url"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/hookdeck/hookdeck-cli/pkg/websocket"
)

// The TUI prints the forwarded URL in two places, and both used to leave the
// forwarding URL's path out or join it with a doubled "/".

// The request line in the event details (and the copied request) is the URL the
// event was forwarded to.
func TestEventDetailsRequestLineJoinsForwardingURLAndEventPath(t *testing.T) {
for _, tc := range []struct {
name string
forwardTo string
eventPath string
want string
}{
{"trailing slash on the forwarding URL", "http://localhost:3500/base/", "/webhook", "POST http://localhost:3500/base/webhook"},
{"no trailing slash on the forwarding URL", "http://localhost:3500/base", "/webhook", "POST http://localhost:3500/base/webhook"},
{"forwarding URL is the root", "http://localhost:3500/", "/webhook", "POST http://localhost:3500/webhook"},
{"event path keeps its own trailing slash", "http://localhost:3500/base/", "/webhook/", "POST http://localhost:3500/base/webhook/"},
} {
t.Run(tc.name, func(t *testing.T) {
m := newDetailsTestModel(t)
target, err := url.Parse(tc.forwardTo)
require.NoError(t, err)
m.cfg.TargetURL = target

event := &EventInfo{
ID: "evt_test",
Data: &websocket.Attempt{Body: websocket.AttemptBody{
Path: tc.eventPath,
Request: websocket.AttemptRequest{Method: "POST", DataString: "{}"},
}},
}

content, copyContent := m.buildEventDetailsContent(event)

assert.Equal(t, tc.want, copyContent.requestLine)
assert.Contains(t, content, tc.want)
})
}
}

// The header lists each connection under its source as "Forwards to → <URL>".
func TestConnectionInfoForwardsToJoinsForwardingURLAndConnectionPath(t *testing.T) {
for _, tc := range []struct {
name string
forwardTo string
cliPath string
want string
}{
{"trailing slash on the forwarding URL", "http://localhost:3030/base/", "/webhook", "Forwards to → http://localhost:3030/base/webhook"},
{"no trailing slash on the forwarding URL", "http://localhost:3030/base", "/webhook", "Forwards to → http://localhost:3030/base/webhook"},
{"forwarding URL has no path", "http://localhost:3030", "/webhook", "Forwards to → http://localhost:3030/webhook"},
} {
t.Run(tc.name, func(t *testing.T) {
m := newConnectionTestModel(t)
target, err := url.Parse(tc.forwardTo)
require.NoError(t, err)
m.cfg.TargetURL = target
m.cfg.Connections[0].Destination = &hookdeck.Destination{
ID: "des_1", Type: "CLI", Config: map[string]interface{}{"path": tc.cliPath},
}

assert.Contains(t, m.renderConnectionInfo(), tc.want)
})
}
}
Loading
Loading