diff --git a/docs/cli-reference.md b/docs/cli-reference.md index eb2231a..95e473b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -190,7 +190,8 @@ fetch -o output.json --clobber example.com/data ### `--copy` Copy the response body to the system clipboard. The response is still printed -to stdout normally. +to stdout normally. Works with all response types including streaming responses +(SSE, NDJSON, gRPC). Responses exceeding 1 MiB are not copied to the clipboard. Requires a clipboard command to be available on the system: diff --git a/integration/integration_test.go b/integration/integration_test.go index 81f1d7c..4e0fb23 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -1732,6 +1732,36 @@ func TestMain(t *testing.T) { // In silent mode, stderr should not contain response metadata. assertBufNotContains(t, res.stderr, "200 OK") }) + + t.Run("copy with SSE response", func(t *testing.T) { + const data = "data:{\"key\":\"val\"}\n\nevent:ev1\ndata: hello\n\n" + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(200) + io.WriteString(w, data) + }) + defer server.Close() + + res := runFetch(t, fetchPath, "--copy", "--no-pager", "--format", "off", server.URL) + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, data) + assertBufNotContains(t, res.stderr, "not supported") + }) + + t.Run("copy with NDJSON response", func(t *testing.T) { + const data = "{\"a\":1}\n{\"b\":2}\n" + server := startServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/x-ndjson") + w.WriteHeader(200) + io.WriteString(w, data) + }) + defer server.Close() + + res := runFetch(t, fetchPath, "--copy", "--no-pager", "--format", "off", server.URL) + assertExitCode(t, 0, res) + assertBufEquals(t, res.stdout, data) + assertBufNotContains(t, res.stderr, "not supported") + }) }) } diff --git a/internal/fetch/clipboard.go b/internal/fetch/clipboard.go index c95ccd5..c3fca97 100644 --- a/internal/fetch/clipboard.go +++ b/internal/fetch/clipboard.go @@ -11,12 +11,41 @@ import ( "github.com/ryanfowler/fetch/internal/core" ) +// limitedBuffer is an io.Writer that captures up to max bytes into buf, +// then silently discards overflow. It always returns len(p), nil so that +// an io.TeeReader using this writer never sees a write error. +type limitedBuffer struct { + buf bytes.Buffer + max int64 + written int64 + overflow bool +} + +func (lb *limitedBuffer) Write(p []byte) (int, error) { + n := len(p) + if lb.overflow { + return n, nil + } + remaining := lb.max - lb.written + if int64(n) > remaining { + lb.overflow = true + if remaining > 0 { + lb.buf.Write(p[:remaining]) + } + lb.written = lb.max + return n, nil + } + lb.buf.Write(p) + lb.written += int64(n) + return n, nil +} + // clipboardCopier handles capturing the raw response body and copying it // to the system clipboard. Use newClipboardCopier to set up body wrapping, // then call finish after the response has been consumed. type clipboardCopier struct { cmd *clipboardCmd - buf *bytes.Buffer + buf *limitedBuffer } // newClipboardCopier sets up clipboard copying for the response. If copying @@ -44,16 +73,9 @@ func newClipboardCopier(r *Request, resp *http.Response) *clipboardCopier { return nil } - contentType := getContentType(resp.Header) - if contentType == TypeSSE || contentType == TypeNDJSON || contentType == TypeGRPC { - p := r.PrinterHandle.Stderr() - core.WriteWarningMsg(p, "--copy is not supported for streaming responses") - return nil - } - - buf := &bytes.Buffer{} + buf := &limitedBuffer{max: maxBodyBytes} resp.Body = readCloserTee{ - Reader: io.TeeReader(io.LimitReader(resp.Body, maxBodyBytes), buf), + Reader: io.TeeReader(resp.Body, buf), Closer: resp.Body, } return &clipboardCopier{cmd: cmd, buf: buf} @@ -62,10 +84,14 @@ func newClipboardCopier(r *Request, resp *http.Response) *clipboardCopier { // finish copies the captured bytes to the system clipboard. It writes a // warning to stderr on failure but never returns an error. func (cc *clipboardCopier) finish(p *core.Printer) { - if cc == nil || cc.buf.Len() == 0 { + if cc == nil || cc.buf.buf.Len() == 0 { + return + } + if cc.buf.overflow { + core.WriteWarningMsg(p, "--copy: response body too large to copy to clipboard") return } - if err := copyToClipboard(cc.cmd, cc.buf.Bytes()); err != nil { + if err := copyToClipboard(cc.cmd, cc.buf.buf.Bytes()); err != nil { core.WriteWarningMsg(p, "unable to copy to clipboard: "+err.Error()) } } diff --git a/internal/fetch/clipboard_test.go b/internal/fetch/clipboard_test.go new file mode 100644 index 0000000..e89c65b --- /dev/null +++ b/internal/fetch/clipboard_test.go @@ -0,0 +1,145 @@ +package fetch + +import ( + "io" + "strings" + "testing" +) + +func TestLimitedBuffer(t *testing.T) { + t.Run("under limit", func(t *testing.T) { + lb := &limitedBuffer{max: 10} + n, err := lb.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Fatalf("expected n=5, got %d", n) + } + if lb.overflow { + t.Fatal("overflow should be false") + } + if lb.buf.String() != "hello" { + t.Fatalf("expected %q, got %q", "hello", lb.buf.String()) + } + }) + + t.Run("at limit", func(t *testing.T) { + lb := &limitedBuffer{max: 5} + n, err := lb.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Fatalf("expected n=5, got %d", n) + } + if lb.overflow { + t.Fatal("overflow should be false at exact limit") + } + if lb.buf.String() != "hello" { + t.Fatalf("expected %q, got %q", "hello", lb.buf.String()) + } + }) + + t.Run("over limit single write", func(t *testing.T) { + lb := &limitedBuffer{max: 3} + n, err := lb.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Fatalf("expected n=5, got %d", n) + } + if !lb.overflow { + t.Fatal("overflow should be true") + } + if lb.buf.String() != "hel" { + t.Fatalf("expected %q, got %q", "hel", lb.buf.String()) + } + }) + + t.Run("multiple writes crossing limit", func(t *testing.T) { + lb := &limitedBuffer{max: 7} + + n, err := lb.Write([]byte("hello")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Fatalf("expected n=5, got %d", n) + } + if lb.overflow { + t.Fatal("overflow should be false after first write") + } + + n, err = lb.Write([]byte("world")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 5 { + t.Fatalf("expected n=5, got %d", n) + } + if !lb.overflow { + t.Fatal("overflow should be true after second write") + } + if lb.buf.String() != "hellowo" { + t.Fatalf("expected %q, got %q", "hellowo", lb.buf.String()) + } + }) + + t.Run("writes after overflow are discarded", func(t *testing.T) { + lb := &limitedBuffer{max: 3} + lb.Write([]byte("hello")) + if !lb.overflow { + t.Fatal("overflow should be true") + } + + n, err := lb.Write([]byte("more")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 4 { + t.Fatalf("expected n=4, got %d", n) + } + if lb.buf.String() != "hel" { + t.Fatalf("expected %q, got %q", "hel", lb.buf.String()) + } + }) + + t.Run("zero max", func(t *testing.T) { + lb := &limitedBuffer{max: 0} + n, err := lb.Write([]byte("a")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if n != 1 { + t.Fatalf("expected n=1, got %d", n) + } + if !lb.overflow { + t.Fatal("overflow should be true") + } + if lb.buf.Len() != 0 { + t.Fatalf("expected empty buffer, got %d bytes", lb.buf.Len()) + } + }) + + t.Run("tee reader not affected by overflow", func(t *testing.T) { + lb := &limitedBuffer{max: 5} + data := "hello world, this is a longer string" + r := io.TeeReader(strings.NewReader(data), lb) + + got, err := io.ReadAll(r) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if string(got) != data { + t.Fatalf("TeeReader should return all data; got %q", got) + } + if !lb.overflow { + t.Fatal("overflow should be true") + } + if lb.buf.String() != "hello" { + t.Fatalf("expected %q in buffer, got %q", "hello", lb.buf.String()) + } + }) +}