diff --git a/cmd/listen.go b/cmd/listen.go index a8af1f4..69dd933 100644 --- a/cmd/listen.go +++ b/cmd/listen.go @@ -3,6 +3,7 @@ package cmd import ( "bytes" "context" + "encoding/json" "errors" "fmt" "io" @@ -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) } @@ -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 @@ -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) @@ -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) @@ -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) @@ -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 +} diff --git a/cmd/listen_test.go b/cmd/listen_test.go new file mode 100644 index 0000000..5197942 --- /dev/null +++ b/cmd/listen_test.go @@ -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) + } +} diff --git a/internal/webhook/webhook.go b/internal/webhook/webhook.go index 39b488e..e40c736 100644 --- a/internal/webhook/webhook.go +++ b/internal/webhook/webhook.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "io" "os" "path/filepath" "strings" @@ -56,6 +57,10 @@ 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, "", " ") @@ -63,8 +68,8 @@ func PrettyPrintJSON(data []byte) error { 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 }