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
105 changes: 90 additions & 15 deletions cmd/listen.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package cmd
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -24,21 +25,23 @@ var (
ErrFailedToGetTCPAddress = errors.New("failed to get TCP address")
forwardURL string
listenAddr string
showPayload bool
listenCommand = &cobra.Command{
Use: "listen",
Short: "Listen for webhooks locally",
Long: `Listen for webhooks locally and forward them to your application.
This command starts a local webhook server that receives events and forwards them to your application.
It's designed for local development and testing.`,
Hidden: true,
RunE: runListen,
Short: "Receive webhook deliveries locally",
Long: `Receive webhook deliveries on a local HTTP server.
This command does not create a public route or change an Ampersand destination.
Use amp tunnel to route a destination to this listener.`,
RunE: runListen,
}
)

func init() {
listenCommand.Flags().StringVar(&forwardURL, "forward-to", "http://localhost:4000/webhook",
"URL to forward webhooks to")
listenCommand.Flags().StringVar(&forwardURL, "forward-to", "", "Optional URL to forward webhooks to")
listenCommand.Flags().StringVar(&listenAddr, "listen", "127.0.0.1:0", "Address to listen on (default is random port)")
listenCommand.Flags().BoolVar(
&showPayload, "show-payload", false, "Print full webhook payloads, including provider field values",
)
rootCmd.AddCommand(listenCommand)
}

Expand Down Expand Up @@ -93,7 +96,11 @@ func runListen(cmd *cobra.Command, args []string) error {

// Print the listen address
fmt.Fprint(os.Stdout, "🎧 Listening on "+addr.IP.String()+":"+port+"\n")
fmt.Fprint(os.Stdout, "ℹ️ Forwarding to: "+forwardURL+"\n")

if forwardURL != "" {
fmt.Fprint(os.Stdout, "ℹ️ Forwarding to: "+forwardURL+"\n")
}

fmt.Fprint(os.Stdout, "Press Ctrl+C to stop\n")

// Wait for interrupt signal
Expand Down Expand Up @@ -158,6 +165,12 @@ func clearListenerPort() {
}

func handleWebhook(writer http.ResponseWriter, req *http.Request) {
handleWebhookWithOptions(writer, req, os.Stdout, forwardURL, showPayload)
}

func handleWebhookWithOptions(
writer http.ResponseWriter, req *http.Request, logWriter io.Writer, forwardTo string, includePayload bool,
) {
// Only accept POST requests
if req.Method != http.MethodPost {
http.Error(writer, "Method not allowed", http.StatusMethodNotAllowed)
Expand All @@ -176,15 +189,19 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) {

req.Body.Close()

// Log the webhook payload

err = webhook.PrettyPrintJSON(body)
err = logWebhook(logWriter, req, body, includePayload)
if err != nil {
logger.FatalErr("error pretty printing JSON", err)
logger.FatalErr("error logging webhook", err)
}

if forwardTo == "" {
writer.WriteHeader(http.StatusNoContent)

return
}

// Forward the request to the application
forwardReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, forwardURL, bytes.NewReader(body))
forwardReq, err := http.NewRequestWithContext(req.Context(), http.MethodPost, forwardTo, bytes.NewReader(body))
if err != nil {
logger.FatalErr("error creating forward request", err)
http.Error(writer, "Internal server error", http.StatusInternalServerError)
Expand All @@ -210,7 +227,7 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) {

resp, err := client.Do(forwardReq)
if err != nil {
logger.FatalErr("error forwarding request to "+forwardURL, err)
logger.FatalErr("error forwarding request to "+forwardTo, err)
// Still return 200 to the original sender
writer.WriteHeader(http.StatusOK)

Expand All @@ -233,3 +250,61 @@ func handleWebhook(writer http.ResponseWriter, req *http.Request) {
logger.FatalErr("error copying response", err)
}
}

type webhookDeliveryMetadata struct {
Method string `json:"method"`
Path string `json:"path"`
ContentType string `json:"contentType,omitempty"`
ByteCount int `json:"byteCount"`
Action string `json:"action,omitempty"`
ItemCount *int `json:"itemCount,omitempty"`
}

func logWebhook(writer io.Writer, req *http.Request, body []byte, includePayload bool) error {
if includePayload {
return webhook.PrettyPrintJSONTo(writer, body)
}

metadata := summarizeWebhook(req, body)

return json.NewEncoder(writer).Encode(metadata)
}

func summarizeWebhook(req *http.Request, body []byte) webhookDeliveryMetadata {
metadata := webhookDeliveryMetadata{
Method: req.Method,
Path: req.URL.Path,
ContentType: req.Header.Get("Content-Type"),
ByteCount: len(body),
}

var envelope struct {
Action string `json:"action"`
Result json.RawMessage `json:"result"`
ResultInfo *struct {
NumRecords *int `json:"numRecords"`
} `json:"resultInfo"`
}

err := json.Unmarshal(body, &envelope)
if err != nil {
return metadata
}

metadata.Action = envelope.Action
if envelope.ResultInfo != nil && envelope.ResultInfo.NumRecords != nil {
metadata.ItemCount = envelope.ResultInfo.NumRecords

return metadata
}

var results []json.RawMessage

err = json.Unmarshal(envelope.Result, &results)
if err == nil {
count := len(results)
metadata.ItemCount = &count
}

return metadata
}
93 changes: 93 additions & 0 deletions cmd/listen_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package cmd

import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)

func TestHandleWebhookWithoutForwarding(t *testing.T) {
t.Parallel()

req := httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(`{"action":"subscribe","result":[]}`))
response := httptest.NewRecorder()

var output bytes.Buffer

handleWebhookWithOptions(response, req, &output, "", false)

if response.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", response.Code, http.StatusNoContent)
}
}

func TestLogWebhookDefaultsToMetadataAndOmitsFieldValues(t *testing.T) {
t.Parallel()

body := []byte(`{"action":"subscribe","result":[{"fields":{"email":"secret@example.com"}}]}`)
req := &http.Request{
Method: http.MethodPost,
URL: &url.URL{Path: "/webhook", RawQuery: "token=secret"},
Header: http.Header{"Content-Type": []string{"application/json"}},
}

var output bytes.Buffer

err := logWebhook(&output, req, body, false)
if err != nil {
t.Fatalf("logWebhook() error = %v", err)
}

got := output.String()
for _, want := range []string{
`"method":"POST"`,
`"path":"/webhook"`,
`"contentType":"application/json"`,
`"byteCount":`,
`"action":"subscribe"`,
`"itemCount":1`,
} {
if !strings.Contains(got, want) {
t.Fatalf("metadata output %q does not contain %q", got, want)
}
}

for _, secret := range []string{"secret@example.com", "token=secret"} {
if strings.Contains(got, secret) {
t.Fatalf("metadata output contains %q", secret)
}
}
}

func TestLogWebhookIncludesPayloadWhenRequested(t *testing.T) {
t.Parallel()

body := []byte(`{"action":"subscribe","result":[{"fields":{"email":"visible@example.com"}}]}`)
req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/webhook"}}

var output bytes.Buffer

err := logWebhook(&output, req, body, true)
if err != nil {
t.Fatalf("logWebhook() error = %v", err)
}

if !strings.Contains(output.String(), "visible@example.com") {
t.Fatalf("payload output = %q", output.String())
}
}

func TestSummarizeWebhookUsesResultInfoCount(t *testing.T) {
t.Parallel()

body := []byte(`{"action":"read","resultInfo":{"type":"url","numRecords":3}}`)
req := &http.Request{Method: http.MethodPost, URL: &url.URL{Path: "/webhook"}}

got := summarizeWebhook(req, body)
if got.ItemCount == nil || *got.ItemCount != 3 {
t.Fatalf("item count = %v, want 3", got.ItemCount)
}
}
9 changes: 7 additions & 2 deletions internal/webhook/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -56,15 +57,19 @@ func ParseEvent(event string) (provider, eventName string) {

// PrettyPrintJSON formats and prints JSON data to stdout with colors.
func PrettyPrintJSON(data []byte) error {
return PrettyPrintJSONTo(os.Stdout, data)
}

func PrettyPrintJSONTo(writer io.Writer, data []byte) error {
var prettyJSON bytes.Buffer

err := json.Indent(&prettyJSON, data, "", " ")
if err != nil {
return err
}

fmt.Fprint(os.Stdout, "\n→ Received webhook event:\n"+prettyJSON.String()+"\n")
fmt.Fprint(os.Stdout, "------------------------------------------\n")
fmt.Fprint(writer, "\n→ Received webhook event:\n"+prettyJSON.String()+"\n")
fmt.Fprint(writer, "------------------------------------------\n")

return nil
}
Loading