Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
30 changes: 30 additions & 0 deletions integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
})
}

Expand Down
50 changes: 38 additions & 12 deletions internal/fetch/clipboard.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand All @@ -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())
}
}
Expand Down
145 changes: 145 additions & 0 deletions internal/fetch/clipboard_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
})
}