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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,25 @@ 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`.

## Expanding companies

Quartr has no server-side company expansion — `/events` rejects `expand` outright and the document endpoints accept only `expand=event`. Pass `--expand company` anyway and the CLI performs the join itself, batching the distinct `companyId` values into `/companies` calls of up to 100 ids:

```bash
quartr events list --tickers ACA --limit 6 --expand company \
--fields id,date,title,companyId,company.name,company.country
```

```text
id date title companyId company.name company.country
243 2021-08-05T00:00:00.000Z Q2 2021 3694 Arcosa Inc US
22156 2022-05-05T16:50:34.000Z Q1 2022 12301 Crédit Agricole S.A. FR
```

`company.name` and `company.country` are picked up automatically when `--fields` is omitted, which is usually how you notice that one ticker matched two companies on different exchanges.

`--expand event,company` works: `event` goes to the API, `company` is joined locally. The join also applies to `get`, runs once across all pages under `--all`, and is skipped for rows that already carry a `company` object. If the `/companies` lookup fails, the rows are still printed and a warning goes to stderr.

## 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:
Expand Down
121 changes: 121 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,127 @@ func TestSortByRejectedOnChildList(t *testing.T) {
}
}

func TestExpandCompanyJoinsNamesClientSide(t *testing.T) {
var eventQueries, companyQueries []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/events":
eventQueries = append(eventQueries, r.URL.RawQuery)
_, _ = w.Write([]byte(`{"data":[
{"id":1,"title":"Q4 2025","companyId":3694},
{"id":2,"title":"Q1 2026","companyId":12301},
{"id":3,"title":"Q2 2026","companyId":3694}
],"pagination":{"nextCursor":null}}`))
case "/companies":
companyQueries = append(companyQueries, r.URL.Query().Get("ids"))
_, _ = w.Write([]byte(`{"data":[
{"id":3694,"name":"Arcosa Inc","country":"US"},
{"id":12301,"name":"Crédit Agricole S.A.","country":"FR"}
],"pagination":{"nextCursor":null}}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json",
"events", "list", "--tickers", "ACA", "--expand", "company"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
for _, want := range []string{"Arcosa Inc", "Crédit Agricole S.A."} {
if !strings.Contains(out.String(), want) {
t.Fatalf("expected joined company %q in output, got %s", want, out.String())
}
}
// expand=company must not reach the API: /events 400s on the parameter.
if len(eventQueries) != 1 || strings.Contains(eventQueries[0], "expand") {
t.Fatalf("expected one events request without expand, got %#v", eventQueries)
}
// Two rows share a companyId, so the join asks for two distinct ids once.
if len(companyQueries) != 1 {
t.Fatalf("expected exactly 1 companies request, got %#v", companyQueries)
}
if companyQueries[0] != "3694,12301" {
t.Fatalf("expected deduped ids 3694,12301, got %q", companyQueries[0])
}
}

func TestExpandCompanyKeepsEventExpansionOnTheWire(t *testing.T) {
var gotExpand string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch r.URL.Path {
case "/documents/transcripts":
gotExpand = r.URL.Query().Get("expand")
_, _ = w.Write([]byte(`{"data":[{"id":9,"companyId":4742,"event":{"title":"Q3 2026"}}],"pagination":{"nextCursor":null}}`))
case "/companies":
_, _ = w.Write([]byte(`{"data":[{"id":4742,"name":"Apple Inc"}],"pagination":{"nextCursor":null}}`))
default:
t.Errorf("unexpected path: %s", r.URL.Path)
}
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json",
"transcripts", "list", "--expand", "event,company"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
if gotExpand != "event" {
t.Fatalf("expected expand=event forwarded without company, got %q", gotExpand)
}
if !strings.Contains(out.String(), "Apple Inc") {
t.Fatalf("expected joined company name, got %s", out.String())
}
}

func TestExpandCompanyRejectedWhereRowsHaveNoCompany(t *testing.T) {
for _, tc := range []struct{ cmd, want string }{
{"companies", "redundant"},
{"document-types", "no companyId"},
} {
var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", "http://127.0.0.1:0",
tc.cmd, "list", "--expand", "company"}, &out, &errOut)
if code != 2 {
t.Fatalf("%s: expected usage exit code 2, got %d; stderr=%s", tc.cmd, code, errOut.String())
}
if !strings.Contains(errOut.String(), tc.want) {
t.Fatalf("%s: expected stderr to mention %q, got %s", tc.cmd, tc.want, errOut.String())
}
}
}

func TestExpandCompanyFailureIsNonFatal(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/companies" {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"Forbidden","statusCode":403}`))
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":1,"title":"Q4 2025","companyId":3694}],"pagination":{"nextCursor":null}}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL, "--format", "json",
"events", "list", "--expand", "company"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected the rows to survive a failed join, got code %d", code)
}
if !strings.Contains(out.String(), "Q4 2025") {
t.Fatalf("expected event rows in stdout, got %s", out.String())
}
if !strings.Contains(errOut.String(), "warning: --expand company") {
t.Fatalf("expected a warning on stderr, 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
30 changes: 26 additions & 4 deletions internal/cli/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,31 @@ import (
"quartr-cli/internal/output"
)

func (a *app) fetchList(path string, params url.Values, all bool, fields []string) error {
// listRequest describes one list-or-paginate call. It exists so callers can
// opt into post-fetch shaping (currently the company join) without growing
// the fetchList signature every time.
type listRequest struct {
path string
params url.Values
all bool
fields []string
joinCompany bool
}

func (a *app) fetchList(req listRequest) error {
ctx := context.Background()
if !all {
path, params := req.path, req.params
if !req.all {
obj, _, err := a.client.GetJSON(ctx, path, params)
if err != nil {
return err
}
return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: fields})
if req.joinCompany {
// dataRows hands back the same maps the response holds, so
// filling them in updates obj.
a.joinCompanies(ctx, dataRows(obj))
}
return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: req.fields})
}

allRows := make([]map[string]any, 0)
Expand Down Expand Up @@ -50,8 +67,13 @@ func (a *app) fetchList(path string, params url.Values, all bool, fields []strin
params.Set("cursor", next)
}

if req.joinCompany {
// One join across every page, so a 5-page pull is still one
// /companies round-trip per 100 distinct ids.
a.joinCompanies(ctx, allRows)
}
wrapped := map[string]any{"data": allRows, "pagination": finalPagination, "count": len(allRows)}
return output.Write(a.out, wrapped, output.Options{Format: a.cfg.Format(), Fields: fields})
return output.Write(a.out, wrapped, output.Options{Format: a.cfg.Format(), Fields: req.fields})
}

func dataRows(obj map[string]any) []map[string]any {
Expand Down
2 changes: 1 addition & 1 deletion internal/cli/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ func addListFlags(fs *flag.FlagSet, lf *listFlags) {
fs.StringVar(&lf.typeIDs, "type-ids", "", "comma-separated type IDs")
fs.StringVar(&lf.eventIDs, "event-ids", "", "comma-separated event IDs")
fs.StringVar(&lf.documentGroupIDs, "document-group-ids", "", "comma-separated document group IDs")
fs.StringVar(&lf.expand, "expand", "", "comma-separated fields to expand, e.g. event")
fs.StringVar(&lf.expand, "expand", "", "comma-separated fields to expand: event (API) or company (joined client-side)")
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; only `events list` supports it (id, date)")
Expand Down
46 changes: 35 additions & 11 deletions internal/cli/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,24 @@ func (a *app) listResource(r resource, args []string) error {
if err := validateSortBy(r, lf.sortBy); err != nil {
return err
}
var joinCompany bool
lf.expand, joinCompany = splitExpand(lf.expand)
if joinCompany {
if err := checkCompanyExpand(r); err != nil {
return err
}
}
if lf.all && !flagWasPassed(args, "limit") {
lf.limit = 500
}

params := lf.toParams(r.listParams, r.name == "companies")
fields := parseCSV(lf.fields)
return a.fetchList(r.listPath, params, lf.all, fields)
return a.fetchList(listRequest{
path: r.listPath,
params: lf.toParams(r.listParams, r.name == "companies"),
all: lf.all,
fields: parseCSV(lf.fields),
joinCompany: joinCompany,
})
}

func (a *app) getResource(r resource, args []string) error {
Expand All @@ -141,28 +152,38 @@ func (a *app) getResource(r resource, args []string) error {
}
fs := newFlagSet(r.name+" get", a.errOut)
fields := fs.String("fields", "", "comma-separated output fields")
expand := fs.String("expand", "", "fields to expand, e.g. event")
expand := fs.String("expand", "", "fields to expand: event (API) or company (joined client-side)")
transcriptVersion := fs.String("transcript-version", "", "live transcript version")
if err := parseInterspersed(fs, args); err != nil {
return err
}
if fs.NArg() != 1 {
return fmt.Errorf("usage: quartr %s get <id>", r.name)
}
apiExpand, joinCompany := splitExpand(*expand)
if joinCompany {
if err := checkCompanyExpand(r); err != nil {
return err
}
}

params := url.Values{}
if r.getParams.allows("expand") && *expand != "" {
params.Set("expand", *expand)
if r.getParams.allows("expand") && apiExpand != "" {
params.Set("expand", apiExpand)
}
if r.getParams.allows("transcriptVersion") && *transcriptVersion != "" {
params.Set("transcriptVersion", *transcriptVersion)
}

ctx := context.Background()
path := strings.ReplaceAll(r.getPath, "{id}", url.PathEscape(fs.Arg(0)))
obj, _, err := a.client.GetJSON(context.Background(), path, params)
obj, _, err := a.client.GetJSON(ctx, path, params)
if err != nil {
return err
}
if joinCompany {
a.joinCompanies(ctx, joinableRows(obj))
}
return output.Write(a.out, obj, output.Options{Format: a.cfg.Format(), Fields: parseCSV(*fields)})
}

Expand Down Expand Up @@ -218,9 +239,12 @@ func (a *app) childListResource(r resource, pathTpl string, allowed paramSet, ar
lf.limit = 500
}

params := lf.toParams(allowed, false)
path := strings.ReplaceAll(pathTpl, "{id}", url.PathEscape(fs.Arg(0)))
return a.fetchList(path, params, lf.all, parseCSV(lf.fields))
return a.fetchList(listRequest{
path: strings.ReplaceAll(pathTpl, "{id}", url.PathEscape(fs.Arg(0))),
params: lf.toParams(allowed, false),
all: lf.all,
fields: parseCSV(lf.fields),
})
}

func (a *app) downloadResource(r resource, args []string) error {
Expand Down Expand Up @@ -347,7 +371,7 @@ func (a *app) handleRequest(args []string) error {
params.Add(k, v)
}
if *paginate {
return a.fetchList(fs.Arg(0), params, true, parseCSV(*fields))
return a.fetchList(listRequest{path: fs.Arg(0), params: params, all: true, fields: parseCSV(*fields)})
}

obj, _, err := a.client.GetJSON(context.Background(), fs.Arg(0), params)
Expand Down
Loading
Loading