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
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,43 @@ quartr transcripts list --event-ids 406161

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

## Type ids

`--type-ids` takes the ids from Quartr's own lookup tables. Both are returned whole — they are bounded catalogs (46 document types, 34 event types) and the default page size used to cut them off at 10, which is why ids like 25 and 46 looked undocumented:

```bash
quartr document-types list --format csv
quartr event-types list --format csv
```

The ones you will reach for most, as of this writing:

| Document type | id | Event type | id |
|---|---|---|---|
| Annual report (10-K) | 11 | Q1 earnings call | 26 |
| Quarterly report (10-Q) | 7 | Q2 earnings call | 27 |
| Earnings release (8-K) | 10 | Q3 earnings call | 28 |
| Annual report (20-F) | 13 | Q4 earnings call | 29 |
| Slides | 5 | H1 / H2 earnings call | 35 / 36 |
| Transcript | 15 | Capital Markets Day | 2 |
| Shareholder letter | 25 | Annual General Meeting | 4 |
| Proxy statement (DEF 14A) | 39 | Investor Day | 31 |
| Proxy statement (DEFM14A) | 46 | Guidance / update | 8 |

Treat the table as a convenience: Quartr adds types over time, so the lookup commands above are the source of truth.

## Tier-restricted endpoints

Some endpoints are gated by API plan and answer with a bare `403 Forbidden`, which reads exactly like a credentials failure — especially since everything else keeps working with the same key. The CLI now says which one it is:

```text
quartr api error: 403 Forbidden: {"message":"Forbidden","statusCode":403}
hint: 403 means this endpoint is not included in your API tier, not that your key is wrong
(a rejected key returns 401). ...
```

Endpoints observed gated this way: `events summary`, `audio list`, `live transcripts list`. A rejected key returns `401` and gets a hint pointing at `quartr auth show` instead.

## Downloads

Download commands first retrieve metadata, then download the URL field from the response.
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ func Run(args []string, out, errOut io.Writer) int {
return 0
}
fmt.Fprintln(errOut, err)
if hint := errorHint(err); hint != "" {
fmt.Fprintln(errOut, hint)
}
var usage *usageError
if errors.As(err, &usage) {
return 2
Expand Down
94 changes: 94 additions & 0 deletions internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -458,6 +458,100 @@ func TestDownloadStatusLineGoesToStderr(t *testing.T) {
}
}

func TestForbiddenIsExplainedAsTierNotAuth(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte(`{"message":"Forbidden","statusCode":403}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"events", "summary", "406161"}, &out, &errOut)
if code != 1 {
t.Fatalf("expected code 1, got %d", code)
}
if !strings.Contains(errOut.String(), "not included in your API tier") {
t.Fatalf("expected a tier explanation, got %s", errOut.String())
}
if !strings.Contains(errOut.String(), "401") {
t.Fatalf("expected the 401 contrast that rules out a bad key, got %s", errOut.String())
}
}

func TestUnauthorizedPointsAtTheKey(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write([]byte(`{"message":"Unauthorized","statusCode":401}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"companies", "list"}, &out, &errOut)
if code != 1 {
t.Fatalf("expected code 1, got %d", code)
}
if !strings.Contains(errOut.String(), "quartr auth show") {
t.Fatalf("expected the key-checking hint, got %s", errOut.String())
}
}

func TestLookupTablesReturnTheWholeCatalog(t *testing.T) {
pages := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pages++
w.Header().Set("Content-Type", "application/json")
if r.URL.Query().Get("cursor") == "" {
if got := r.URL.Query().Get("limit"); got != "500" {
t.Errorf("expected the catalog to be fetched 500 at a time, got limit=%q", got)
}
_, _ = w.Write([]byte(`{"data":[{"id":11,"name":"Annual report","form":"10-K"}],"pagination":{"nextCursor":"p2"}}`))
return
}
_, _ = w.Write([]byte(`{"data":[{"id":25,"name":"Shareholder letter","form":""}],"pagination":{"nextCursor":null}}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"document-types", "list"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
if pages != 2 {
t.Fatalf("expected the catalog to be paged to exhaustion, got %d requests", pages)
}
// typeId 25 lives past the old default page of 10, which is why it read
// as undocumented.
if !strings.Contains(out.String(), "Shareholder letter") {
t.Fatalf("expected the tail of the catalog, got %s", out.String())
}
}

func TestExplicitLimitStillPagesTheCatalog(t *testing.T) {
requests := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests++
if got := r.URL.Query().Get("limit"); got != "5" {
t.Errorf("expected limit=5 to be honored, got %q", got)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[{"id":11,"name":"Annual report"}],"pagination":{"nextCursor":"p2"}}`))
}))
defer srv.Close()

var out, errOut bytes.Buffer
code := Run([]string{"--no-config", "--api-key", "secret", "--base-url", srv.URL,
"document-types", "list", "--limit", "5"}, &out, &errOut)
if code != 0 {
t.Fatalf("expected code 0, got %d; stderr=%s", code, errOut.String())
}
if requests != 1 {
t.Fatalf("expected an explicit --limit to opt out of the full catalog, got %d requests", requests)
}
}

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

import "fmt"
import (
"errors"
"fmt"
"net/http"

"quartr-cli/internal/quartr"
)

// 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
Expand All @@ -13,3 +19,28 @@ func (e *usageError) Error() string { return e.msg }
func usagef(format string, args ...any) error {
return &usageError{msg: fmt.Sprintf(format, args...)}
}

// errorHint returns an extra line to print under an API error, for the two
// statuses users reliably misread.
//
// Quartr answers a tier-gated endpoint with a bare {"message":"Forbidden"},
// which is indistinguishable from a credentials problem — and since the rest
// of the CLI keeps working on the same key, the natural conclusion is that
// auth is broken. It is not: 403 is entitlement, 401 is authentication.
func errorHint(err error) string {
var apiErr *quartr.APIError
if !errors.As(err, &apiErr) {
return ""
}
switch apiErr.StatusCode {
case http.StatusForbidden:
return "hint: 403 means this endpoint is not included in your API tier, not that your key is wrong " +
"(a rejected key returns 401). Every other endpoint keeps working with the same key. " +
"Endpoints seen gated this way: `events summary`, `audio list`, `live transcripts list`."
case http.StatusUnauthorized:
return "hint: 401 means the API key was rejected. Check `quartr auth show`, QUARTR_API_KEY, " +
"and any --api-key flag."
default:
return ""
}
}
5 changes: 5 additions & 0 deletions internal/cli/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,11 @@ func (a *app) listResource(r resource, args []string) error {
if err := a.applyQualifiedTickers(context.Background(), r, &lf); err != nil {
return err
}
// A lookup table is only useful whole: the type ids people need most
// (25 = shareholder letter, 46 = DEFM14A) live past the default page.
if r.fullCatalog && !flagWasPassed(args, "limit") && !flagWasPassed(args, "cursor") {
lf.all = true
}
if lf.all && !flagWasPassed(args, "limit") {
lf.limit = 500
}
Expand Down
8 changes: 8 additions & 0 deletions internal/cli/help.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,14 @@ Common list flags:
strings.ReplaceAll(sortRecipe(r), "\n", "\n "))
}
}
if r.fullCatalog {
fmt.Fprintf(a.out, `
Catalog:
`+"`quartr %s list`"+` returns the whole table by default: it is a lookup
list, and the ids people need most sit past the first page. Pass --limit
to page through it instead.
`, r.name)
}
if r.downloadField != "" {
fmt.Fprintf(a.out, `
Downloads:
Expand Down
22 changes: 14 additions & 8 deletions internal/cli/resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ type resource struct {
// means the endpoint has no sortBy parameter at all, which the CLI
// reports instead of dropping the flag on the floor.
sortFields paramSet
// fullCatalog marks a bounded lookup table that is only useful whole.
// Those endpoints page like any other, so the default limit of 10 turns
// a 46-row catalog into a 10-row one with nothing to say it was cut.
fullCatalog bool
}

var (
Expand Down Expand Up @@ -175,16 +179,18 @@ var resources = map[string]resource{
getParams: getLiveParams,
},
"event-types": {
name: "event-types",
listPath: "/event-types",
getPath: "/event-types/{id}",
listParams: simpleListParams,
name: "event-types",
listPath: "/event-types",
getPath: "/event-types/{id}",
listParams: simpleListParams,
fullCatalog: true,
},
"document-types": {
name: "document-types",
listPath: "/document-types",
getPath: "/document-types/{id}",
listParams: simpleListParams,
name: "document-types",
listPath: "/document-types",
getPath: "/document-types/{id}",
listParams: simpleListParams,
fullCatalog: true,
},
}

Expand Down
Loading