diff --git a/README.md b/README.md index 15b2fb5..a0c8788 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 26c8a6d..cfd9163 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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) { diff --git a/internal/cli/fetch.go b/internal/cli/fetch.go index f2c4719..1d15147 100644 --- a/internal/cli/fetch.go +++ b/internal/cli/fetch.go @@ -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) @@ -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 { diff --git a/internal/cli/flags.go b/internal/cli/flags.go index 26a71a7..edcd293 100644 --- a/internal/cli/flags.go +++ b/internal/cli/flags.go @@ -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)") diff --git a/internal/cli/handlers.go b/internal/cli/handlers.go index 924bddb..7942bf7 100644 --- a/internal/cli/handlers.go +++ b/internal/cli/handlers.go @@ -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 { @@ -141,7 +152,7 @@ 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 @@ -149,20 +160,30 @@ func (a *app) getResource(r resource, args []string) error { if fs.NArg() != 1 { return fmt.Errorf("usage: quartr %s get ", 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)}) } @@ -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 { @@ -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) diff --git a/internal/cli/join.go b/internal/cli/join.go new file mode 100644 index 0000000..56d8d0b --- /dev/null +++ b/internal/cli/join.go @@ -0,0 +1,140 @@ +package cli + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "slices" + "strconv" + "strings" +) + +// companyJoinBatch caps how many ids go into one /companies?ids=... request. +const companyJoinBatch = 100 + +// splitExpand separates the client-side "company" expansion from the values +// the API understands. Quartr has no company expansion: /events rejects the +// parameter entirely ("property expand should not exist") and the document +// endpoints accept only "event". So the CLI performs that join itself rather +// than forwarding a value that is guaranteed to 400. +// It returns the expand value to forward and whether the caller asked for the +// client-side company join. +func splitExpand(expand string) (string, bool) { + kept := make([]string, 0, 2) + joinCompany := false + for _, v := range parseCSV(expand) { + if strings.EqualFold(v, "company") { + joinCompany = true + continue + } + kept = append(kept, v) + } + return strings.Join(kept, ","), joinCompany +} + +// checkCompanyExpand reports whether --expand company makes sense for r. +func checkCompanyExpand(r resource) error { + if r.name == "companies" { + return usagef("--expand company is redundant for `quartr companies`; the rows already are companies") + } + if !r.listParams.allows("companyIds") { + return usagef("--expand company is not available for `quartr %s`; its rows carry no companyId", r.name) + } + return nil +} + +// joinCompanies fills in row["company"] for every row that carries a +// companyId, by batch-fetching /companies. Rows that already embed a company +// object are left alone, so this becomes a no-op if Quartr ever starts +// expanding server-side. +// +// A lookup failure is reported on stderr and leaves the rows unexpanded +// rather than failing the whole command: the caller still gets their data. +func (a *app) joinCompanies(ctx context.Context, rows []map[string]any) { + ids := distinctCompanyIDs(rows) + if len(ids) == 0 { + return + } + + byID := make(map[string]map[string]any, len(ids)) + for chunk := range slices.Chunk(ids, companyJoinBatch) { + params := url.Values{} + params.Set("ids", strings.Join(chunk, ",")) + params.Set("limit", strconv.Itoa(len(chunk))) + obj, _, err := a.client.GetJSON(ctx, "/companies", params) + if err != nil { + fmt.Fprintf(a.errOut, "warning: --expand company: %v\n", err) + return + } + for _, company := range dataRows(obj) { + if id := idKey(company["id"]); id != "" { + byID[id] = company + } + } + } + + missing := 0 + for _, row := range rows { + if _, ok := row["company"].(map[string]any); ok { + continue + } + id := idKey(row["companyId"]) + if id == "" { + continue + } + if company, ok := byID[id]; ok { + row["company"] = company + continue + } + missing++ + } + if missing > 0 { + fmt.Fprintf(a.errOut, "warning: --expand company: no company record for %d of %d rows\n", missing, len(rows)) + } +} + +func distinctCompanyIDs(rows []map[string]any) []string { + seen := make(map[string]bool, len(rows)) + ids := make([]string, 0, len(rows)) + for _, row := range rows { + if _, ok := row["company"].(map[string]any); ok { + continue + } + id := idKey(row["companyId"]) + if id == "" || seen[id] { + continue + } + seen[id] = true + ids = append(ids, id) + } + return ids +} + +// idKey normalizes an id from a decoded JSON body into a comparable string. +// Bodies are decoded with UseNumber, so ids arrive as json.Number. +func idKey(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return strings.TrimSpace(t) + case json.Number: + return t.String() + default: + return fmt.Sprintf("%v", t) + } +} + +// joinableRows returns the mutable row maps inside a decoded response, +// handling both the list shape ({"data": [ ... ]}) and the single-object +// shape ({"data": { ... }}) returned by get endpoints. +func joinableRows(obj map[string]any) []map[string]any { + if rows := dataRows(obj); len(rows) > 0 { + return rows + } + if single, ok := obj["data"].(map[string]any); ok { + return []map[string]any{single} + } + return nil +} diff --git a/internal/output/output.go b/internal/output/output.go index 5597b48..7703532 100644 --- a/internal/output/output.go +++ b/internal/output/output.go @@ -208,7 +208,8 @@ func chooseFields(rows []map[string]any, explicit []string) []string { } preferred := []string{ "id", "name", "displayName", "title", "parent", "category", "form", - "companyId", "eventId", "typeId", "documentGroupId", "fiscalYear", "fiscalPeriod", + "companyId", "company.name", "company.country", + "eventId", "typeId", "documentGroupId", "fiscalYear", "fiscalPeriod", "date", "state", "wentLiveAt", "qna", "startTimestamp", "endTimestamp", "level", "fileUrl", "streamUrl", "audio", "transcript", "pdfUrl", "imageUrl", "backlinkUrl", "country", "tickers", "isins", "cik",