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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,24 @@ Endpoint-specific filters are also available where relevant:

Companies are the one common exception where Quartr uses `ids` instead of `companyIds`. This CLI maps `--company-ids` to `ids` for `companies list`.

## Sorting

`--sort-by` is only implemented by `/events`, where the accepted fields are `id` and `date`. Every other list endpoint rejects the parameter outright, so the CLI now fails with exit code 2 instead of dropping the flag:

```bash
quartr transcripts list --tickers AAPL --sort-by date
# --sort-by is not supported by `quartr transcripts list` ... (exit 2)
```

This matters because the failure used to be invisible: document endpoints return rows in insertion order, so the newest filings and calls are simply absent from the first page. Sort events first, then fetch documents by event id:

```bash
quartr events list --tickers AAPL --sort-by date --direction desc --limit 5
quartr transcripts list --event-ids 406161
```

`--direction asc|desc` is accepted by every list endpoint, but it reverses insertion order, not date order.

## Downloads

Download commands first retrieve metadata, then download the URL field from the response.
Expand Down
4 changes: 4 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ func Run(args []string, out, errOut io.Writer) int {
return 0
}
fmt.Fprintln(errOut, err)
var usage *usageError
if errors.As(err, &usage) {
return 2
}
return 1
}
return 0
Expand Down
69 changes: 69 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,75 @@ func TestDownloadAllowsFlagsAfterID(t *testing.T) {
}
}

func TestSortByRejectedOnUnsortableResource(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
t.Fatalf("expected no request, got %s", r.URL)
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"transcripts", "list", "--tickers", "AAPL", "--sort-by", "date"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected usage exit code 2, got %d; stderr=%s", code, errOut.String())
}
for _, want := range []string{"--sort-by is not supported", "quartr events list", "--event-ids"} {
if !strings.Contains(errOut.String(), want) {
t.Fatalf("expected stderr to mention %q, got %s", want, errOut.String())
}
}
}

func TestSortByRejectsUnknownFieldOnEvents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
t.Fatalf("expected no request, got %s", r.URL)
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"events", "list", "--sort-by", "title"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected usage exit code 2, got %d; stderr=%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "supported sort fields: id, date") {
t.Fatalf("expected supported field list, got %s", errOut.String())
}
}

func TestSortByForwardedOnEvents(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("sortBy"); got != "date" {
t.Fatalf("expected sortBy=date, got %q", got)
}
if got := r.URL.Query().Get("direction"); got != "desc" {
t.Fatalf("expected direction=desc, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":1,"title":"Q4 2025"}],"pagination":{"nextCursor":null}}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"events", "list", "--sort-by", "date", "--direction", "desc"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
}

func TestSortByRejectedOnChildList(t *testing.T) {
var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", "http://127.0.0.1:0",
"reports", "pages", "123", "--sort-by", "date"}, &out, &errOut)
if code != 2 {
t.Fatalf("expected usage exit code 2, got %d; stderr=%s", code, errOut.String())
}
if !strings.Contains(errOut.String(), "quartr reports pages") {
t.Fatalf("expected command name in message, got %s", errOut.String())
}
}

func TestListAllFollowsPagination(t *testing.T) {
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
Expand Down
15 changes: 15 additions & 0 deletions internal/cli/errors.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package cli

import "fmt"

// usageError marks a failure caused by the command line the user typed
// rather than by the API or the network. Run maps it to exit code 2 so
// scripts can tell "you asked for something impossible" apart from "the
// request failed".
type usageError struct{ msg string }

func (e *usageError) Error() string { return e.msg }

func usagef(format string, args ...any) error {
return &usageError{msg: fmt.Sprintf(format, args...)}
}
2 changes: 1 addition & 1 deletion internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) {
fs.StringVar(&lf.expand, "expand", "", "comma-separated fields to expand, e.g. event")
fs.StringVar(&lf.states, "states", "", "comma-separated live states")
fs.StringVar(&lf.transcriptVersion, "transcript-version", "", "live transcript stream version, e.g. 1.7")
fs.StringVar(&lf.sortBy, "sort-by", "", "sort field for endpoints that support it")
fs.StringVar(&lf.sortBy, "sort-by", "", "sort field; only `events list` supports it (id, date)")
fs.StringVar(&lf.levels, "levels", "", "comma-separated chapter levels")
}

Expand Down
7 changes: 7 additions & 0 deletions internal/cli/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ func (a *app) listResource(r resource, args []string) error {
if fs.NArg() > 0 {
return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " "))
}
if err := validateSortBy(r, lf.sortBy); err != nil {
return err
}
if lf.all && !flagWasPassed(args, "limit") {
lf.limit = 500
}
Expand Down Expand Up @@ -207,6 +210,10 @@ func (a *app) childListResource(r resource, pathTpl string, allowed paramSet, ar
if fs.NArg() != 1 {
return fmt.Errorf("usage: quartr %s %s <id>", r.name, child)
}
if strings.TrimSpace(lf.sortBy) != "" {
return usagef("--sort-by is not supported by `quartr %s %s`; rows are returned in document order",
r.name, child)
}
if lf.all && !flagWasPassed(args, "limit") {
lf.limit = 500
}
Expand Down
12 changes: 11 additions & 1 deletion internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,17 @@ Common list flags:
--limit N page size, max 500
--all follow pagination.nextCursor
--fields a,b,c output fields for table/csv

`)
if r.listPath != "" {
fmt.Fprintf(a.out, "\nSorting:\n")
if len(r.sortFields) > 0 {
fmt.Fprintf(a.out, " --sort-by %s [--direction asc|desc]\n", strings.Join(r.sortFields, "|"))
} else {
fmt.Fprintf(a.out, " --sort-by is rejected here (the endpoint has no sortBy parameter).\n %s\n",
strings.ReplaceAll(sortRecipe(r), "\n", "\n "))
}
}
fmt.Fprint(a.out, `
Examples:
`)
switch r.name {
Expand Down
51 changes: 51 additions & 0 deletions internal/cli/resources.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
package cli

import (
"fmt"
"strings"
)

type paramSet []string

func params(xs ...string) paramSet {
Expand Down Expand Up @@ -42,6 +47,10 @@ type resource struct {
listParams paramSet
getParams paramSet
summaryParams paramSet
// sortFields lists the values the endpoint accepts for sortBy. Empty
// means the endpoint has no sortBy parameter at all, which the CLI
// reports instead of dropping the flag on the floor.
sortFields paramSet
}

var (
Expand Down Expand Up @@ -69,6 +78,11 @@ var (
summaryParams = params("length", "plain")
getExpandParams = params("expand")
getLiveParams = params("transcriptVersion")

// eventSortFields mirrors the enum the API reports when sortBy is
// invalid: "sortBy must be one of the following values: id, date".
// /events is the only list endpoint that accepts the parameter.
eventSortFields = params("id", "date")
)

var resources = map[string]resource{
Expand All @@ -85,6 +99,7 @@ var resources = map[string]resource{
summaryPath: "/events/{id}/summary",
listParams: mergeParams(baseListParams, params("typeIds", "sortBy")),
summaryParams: summaryParams,
sortFields: eventSortFields,
},
"documents": {
name: "documents",
Expand Down Expand Up @@ -177,3 +192,39 @@ func resourceByName(name string) (resource, bool) {
r, ok := resources[name]
return r, ok
}

// validateSortBy rejects --sort-by on list endpoints that have no sortBy
// parameter. Forwarding it is a 400 and dropping it is worse: rows come back
// in insertion order, so a caller who trusts the flag silently reads stale
// documents off the first page.
func validateSortBy(r resource, sortBy string) error {
sortBy = strings.TrimSpace(sortBy)
if sortBy == "" {
return nil
}
if len(r.sortFields) > 0 {
if r.sortFields.allows(sortBy) {
return nil
}
return usagef("--sort-by %s is not supported by `quartr %s list`; supported sort fields: %s",
sortBy, r.name, strings.Join(r.sortFields, ", "))
}
return usagef("--sort-by is not supported by `quartr %s list`: the Quartr endpoint has no sortBy "+
"parameter, so rows come back in insertion order and the newest items may be missing from the "+
"first page.\n\n%s", r.name, sortRecipe(r))
}

// sortRecipe is the "do this instead" paragraph shown both by the --sort-by
// error and by `quartr <resource> --help`.
func sortRecipe(r resource) string {
const directionNote = "`--direction asc|desc` is accepted here, but it reverses insertion order, not date order."
if !r.listParams.allows("eventIds") {
return "Only `quartr events list` supports --sort-by (fields: " +
strings.Join(eventSortFields, ", ") + ").\n" + directionNote
}
return fmt.Sprintf(`Sort events first, then fetch by event id:
quartr events list --tickers AAPL --sort-by date --direction desc --limit 5
quartr %s list --event-ids <id>

%s`, r.name, directionNote)
}
Loading