diff --git a/README.md b/README.md index b72004d..3d984fc 100644 --- a/README.md +++ b/README.md @@ -294,6 +294,15 @@ quartr reports download 12345 --output annual-report.pdf quartr slides download 12345 --url-field fileUrl ``` +A download always writes a file unless you ask for stdout. `--output -` streams the document itself: + +```bash +quartr transcripts download 432907 --output - > transcript.json +quartr transcripts download 432907 --output - | jq '.transcript.text' +``` + +Without `--output`, the file is named after the resource and id (`transcripts-432907.json`) in the current directory. The `Saved ` confirmation goes to **stderr**, so a plain `> file` redirect never captures it. + By default, the CLI does not include `x-api-key` when fetching a returned file URL. Add `--with-api-key` if your URL requires it: ```bash diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 20b127c..bb3ba9a 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -384,6 +384,80 @@ func TestCompaniesResolveRejectsNameSearch(t *testing.T) { } } +// downloadServer serves one document plus its metadata record. +func downloadServer(t *testing.T, body string) *httptest.Server { + t.Helper() + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/documents/transcripts/abc": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"data":{"id":"abc","fileUrl":"` + srv.URL + `/file/transcript.json"}}`)) + case "/file/transcript.json": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + default: + t.Errorf("unexpected path: %s", r.URL.Path) + } + })) + return srv +} + +func TestDownloadToStdout(t *testing.T) { + const body = `{"transcript":"hello"}` + srv := downloadServer(t, body) + defer srv.Close() + + dir := t.TempDir() + t.Chdir(dir) + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "transcripts", "download", "abc", "--output", "-"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + // stdout is the document, byte for byte — a redirect must capture this + // and nothing else. + if out.String() != body { + t.Fatalf("expected the document on stdout, got %q", out.String()) + } + if errOut.Len() != 0 { + t.Fatalf("expected nothing on stderr, got %q", errOut.String()) + } + // And no stray file is left behind next to the redirect target. + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("expected no files written, got %v", entries) + } +} + +func TestDownloadStatusLineGoesToStderr(t *testing.T) { + srv := downloadServer(t, `{"ok":true}`) + defer srv.Close() + + t.Chdir(t.TempDir()) + + var out, errOut bytes.Buffer + code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, + "transcripts", "download", "abc"}, &out, &errOut) + if code != 0 { + t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String()) + } + if out.Len() != 0 { + t.Fatalf("expected clean stdout, got %q", out.String()) + } + if !strings.Contains(errOut.String(), "Saved transcripts-abc.json") { + t.Fatalf("expected the saved path on stderr, got %q", errOut.String()) + } + if _, err := os.Stat("transcripts-abc.json"); err != nil { + t.Fatalf("expected the default file to exist: %v", err) + } +} + func TestListAllFollowsPagination(t *testing.T) { requests := 0 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index ecbb71a..72e99f9 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -303,7 +303,7 @@ func (a *app) downloadResource(r resource, args []string) error { return fmt.Errorf("%s does not have a configured download URL field", r.name) } fs := newFlagSet(r.name+" download", a.errOut) - outPath := fs.String("output", "", "output file path; defaults to a name based on id and URL") + outPath := fs.String("output", "", "output file path, or - to stream to stdout; defaults to a name based on id and URL") urlField := fs.String("url-field", r.downloadField, "metadata URL field to download") withAPIKey := fs.Bool("with-api-key", false, "include x-api-key when fetching the file URL") expand := fs.String("expand", "", "fields to expand on the metadata request") @@ -330,27 +330,43 @@ func (a *app) downloadResource(r resource, args []string) error { return err } + apiKey := "" + if *withAPIKey { + apiKey = a.cfg.APIKey() + } + + // `--output -` streams the document itself to stdout so it can be piped + // or redirected. Everything else this command prints goes to stderr, so + // `quartr transcripts download --output - > f.json` writes the + // document and nothing else. + if *outPath == "-" { + _, err := a.client.Download(context.Background(), downloadURL, apiKey, a.out) + return err + } + dest := *outPath if dest == "" { dest = defaultFileName(r.name, id, downloadURL) } - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil && filepath.Dir(dest) != "." { - return err + if dir := filepath.Dir(dest); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } } f, err := os.Create(dest) if err != nil { return err } - defer f.Close() + defer func() { _ = f.Close() }() - apiKey := "" - if *withAPIKey { - apiKey = a.cfg.APIKey() - } if _, err := a.client.Download(context.Background(), downloadURL, apiKey, f); err != nil { return err } - fmt.Fprintf(a.out, "Saved %s\n", dest) + if err := f.Close(); err != nil { + return fmt.Errorf("write %s: %w", dest, err) + } + // stderr, not stdout: a redirect is supposed to capture the document. + fmt.Fprintf(a.errOut, "Saved %s\n", dest) return nil } diff --git a/internal/cli/help.go b/internal/cli/help.go index fbdde9b..cd19e4f 100644 --- a/internal/cli/help.go +++ b/internal/cli/help.go @@ -113,6 +113,14 @@ Common list flags: strings.ReplaceAll(sortRecipe(r), "\n", "\n ")) } } + if r.downloadField != "" { + fmt.Fprintf(a.out, ` +Downloads: + download writes ./%s-. and reports the path on stderr + download --output P writes P + download --output - streams the document to stdout, nothing else +`, r.name) + } fmt.Fprint(a.out, ` Examples: `) @@ -122,7 +130,7 @@ Examples: case "events": fmt.Fprint(a.out, " quartr events list --tickers AAPL --sort-by date --direction desc\n quartr events summary 128301 --length long --plain\n") case "transcripts": - fmt.Fprint(a.out, " quartr transcripts list --tickers AAPL --expand event\n quartr transcripts download 432907 --output transcript.json\n") + fmt.Fprint(a.out, " quartr transcripts list --tickers AAPL --expand event\n quartr transcripts download 432907 --output transcript.json\n quartr transcripts download 432907 --output - | jq .\n") case "live-transcripts": fmt.Fprint(a.out, " quartr live transcripts list --states live,willBeLive\n quartr live transcripts stream 127537 --transcript-version 1.7\n") default: