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
31 changes: 24 additions & 7 deletions internal/api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ func (e *APIError) Error() string {
return e.Message
}

// IsNotFound reports whether err is an API 404 — for the resolve-first
// endpoints that means "no such link, or not yours".
func IsNotFound(err error) bool {
var apiErr *APIError
return errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound
}

func (c *Client) do(ctx context.Context, method, path string, query url.Values, body, out any) error {
resp, err := c.request(ctx, method, path, query, body)
if err != nil {
Expand All @@ -92,14 +99,24 @@ func (c *Client) request(ctx context.Context, method, path string, query url.Val
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusUnauthorized && creds != nil &&
creds.Mode == auth.ModeDevice && creds.RefreshToken != "" {
resp.Body.Close()
if creds, err = c.refreshTokens(ctx, creds); err != nil {
return nil, err
if resp.StatusCode == http.StatusUnauthorized {
// The public stats endpoint answers 401 for password-protected
// links. That is a property of the link, not of the session, so
// refreshing tokens can't help — and the CLI doesn't supply link
// passwords, so say so instead of blaming the login.
switch resp.Header.Get("X-Error-Code") {
case "password_required", "invalid_password":
resp.Body.Close()
return nil, errors.New("this link's stats are password protected")
}
if resp, err = c.send(ctx, method, path, query, body, creds); err != nil {
return nil, err
if creds != nil && creds.Mode == auth.ModeDevice && creds.RefreshToken != "" {
resp.Body.Close()
if creds, err = c.refreshTokens(ctx, creds); err != nil {
return nil, err
}
if resp, err = c.send(ctx, method, path, query, body, creds); err != nil {
return nil, err
}
}
}
return resp, nil
Expand Down
37 changes: 37 additions & 0 deletions internal/api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"

Expand Down Expand Up @@ -173,3 +174,39 @@ func TestClientHeaderKeptOnSameHostRedirect(t *testing.T) {
t.Fatalf("X-Spoo-Client after same-host redirect = %q, want cli/dev", gotClient)
}
}

// a password-protected link answers 401 too, but that's about the link,
// not the session — no token refresh, and an honest error instead of
// "session expired".
func TestDo401PasswordRequiredSkipsRefresh(t *testing.T) {
var refreshCalls atomic.Int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/auth/device/refresh" {
refreshCalls.Add(1)
w.Write([]byte(`{"access_token":"newAT","refresh_token":"newRT"}`))
return
}
w.Header().Set("X-Error-Code", "password_required")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"Password required","code":"password_required"}`))
}))
defer srv.Close()

store := newTestStore(t, &auth.Credentials{Mode: auth.ModeDevice, AccessToken: "goodAT", RefreshToken: "goodRT"})
c := New(srv.URL, store)
err := c.do(context.Background(), http.MethodGet, "/api/v1/public/stats/secret", nil, nil, nil)
if err == nil || !strings.Contains(err.Error(), "password protected") {
t.Fatalf("err = %v, want a password-protected explanation", err)
}
if refreshCalls.Load() != 0 {
t.Fatalf("refresh called %d times, want 0", refreshCalls.Load())
}
// the healthy session must survive untouched
got, loadErr := store.Load()
if loadErr != nil {
t.Fatal(loadErr)
}
if got.AccessToken != "goodAT" || got.RefreshToken != "goodRT" {
t.Fatalf("tokens rotated pointlessly: %+v", got)
}
}
16 changes: 14 additions & 2 deletions internal/api/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,27 @@ import (
"io"
"mime"
"net/http"
"net/url"
)

// Export downloads stats in the given format (json, csv, xlsx, xml).
// Export downloads account-wide stats in the given format (json, csv,
// xlsx, xml). Auth is required — anonymous export no longer exists.
// Returns the server-suggested filename and the file contents.
// csv arrives as a ZIP archive (one CSV per dimension).
func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (string, []byte, error) {
return c.export(ctx, "/api/v1/export", q, format)
}

// ExportLink downloads one owned link's stats by its url id (resolve
// an alias with ResolveAlias first). Unknown and foreign ids both 404.
func (c *Client) ExportLink(ctx context.Context, urlID string, q StatsQuery, format string) (string, []byte, error) {
return c.export(ctx, "/api/v1/export/links/"+url.PathEscape(urlID), q, format)
}

func (c *Client) export(ctx context.Context, path string, q StatsQuery, format string) (string, []byte, error) {
v := q.values()
v.Set("format", format)
resp, err := c.request(ctx, http.MethodGet, "/api/v1/export", v, nil)
resp, err := c.request(ctx, http.MethodGet, path, v, nil)
if err != nil {
return "", nil, err
}
Expand Down
57 changes: 46 additions & 11 deletions internal/api/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ import (
"strings"
)

// StatsQuery parameterizes the authed stats endpoints. The account
// endpoint accepts every field; the per-link endpoint rejects a
// short_code group_by (the link is already picked by the path).
type StatsQuery struct {
ShortCode string
Scope string // "all" (authed, optional code) or "anon" (code required)
StartDate string
EndDate string
GroupBy []string // time, browser, os, country, city, referrer, short_code
GroupBy []string // time, browser, os, country, city, referrer; account-only: short_code
Timezone string // IANA name
// Filters narrows results server-side; keys are the filterable
// dimensions (browser, os, country, city, referrer, short_code).
Expand All @@ -23,12 +24,6 @@ type StatsQuery struct {

func (q StatsQuery) values() url.Values {
v := url.Values{}
if q.Scope != "" {
v.Set("scope", q.Scope)
}
if q.ShortCode != "" {
v.Set("short_code", q.ShortCode)
}
if q.StartDate != "" {
v.Set("start_date", q.StartDate)
}
Expand Down Expand Up @@ -65,9 +60,10 @@ type StatsTimeRange struct {
// StatsResponse keeps Metrics loosely typed: keys are dynamic
// ("clicks_by_browser", "unique_clicks_by_time", ...) and each point
// carries its dimension label under the dimension's own name.
// The wire still carries a legacy "scope" key — tolerated, never read.
type StatsResponse struct {
Scope string `json:"scope"`
ShortCode string `json:"short_code"`
URLID string `json:"url_id"` // per-link responses echo the link
Alias string `json:"alias"`
Summary StatsSummary `json:"summary"`
TimeRange StatsTimeRange `json:"time_range"`
Metrics map[string][]map[string]any `json:"metrics"`
Expand Down Expand Up @@ -102,10 +98,49 @@ func (r *StatsResponse) Points(dimension, metric string) []MetricPoint {
return out
}

// Stats aggregates clicks across every link the account owns.
// Auth is required — anonymous stats live on PublicStats.
func (c *Client) Stats(ctx context.Context, q StatsQuery) (*StatsResponse, error) {
var out StatsResponse
if err := c.do(ctx, http.MethodGet, "/api/v1/stats", q.values(), nil, &out); err != nil {
return nil, err
}
return &out, nil
}

// LinkStats returns stats for one owned link by its url id (resolve an
// alias with ResolveAlias first). Unknown and foreign ids both 404.
func (c *Client) LinkStats(ctx context.Context, urlID string, q StatsQuery) (*StatsResponse, error) {
var out StatsResponse
path := "/api/v1/stats/links/" + url.PathEscape(urlID)
if err := c.do(ctx, http.MethodGet, path, q.values(), nil, &out); err != nil {
return nil, err
}
return &out, nil
}

// PublicStats returns anyone's per-link stats without auth. The
// endpoint takes only a date range and timezone — no group_by — and
// answers with every dimension at once; private links 404 and
// password-protected ones 401. The {generation, link, stats} envelope
// is unwrapped to the standard stats wire.
func (c *Client) PublicStats(ctx context.Context, shortCode, startDate, endDate, timezone string) (*StatsResponse, error) {
Comment thread
Zingzy marked this conversation as resolved.
v := url.Values{}
if startDate != "" {
v.Set("start_date", startDate)
}
if endDate != "" {
v.Set("end_date", endDate)
}
if timezone != "" {
v.Set("timezone", timezone)
}
var out struct {
Stats StatsResponse `json:"stats"`
}
path := "/api/v1/public/stats/" + url.PathEscape(shortCode)
if err := c.do(ctx, http.MethodGet, path, v, nil, &out); err != nil {
return nil, err
}
return &out.Stats, nil
}
92 changes: 89 additions & 3 deletions internal/api/stats_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import (
func TestStatsQueryAndDecode(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("group_by") != "time,browser" || q.Get("scope") != "all" || q.Get("timezone") != "UTC" {
if q.Get("group_by") != "time,browser" || q.Get("timezone") != "UTC" {
t.Errorf("unexpected query: %v", q)
}
if q.Has("scope") {
t.Errorf("scope param must not be sent: %v", q)
}
w.Write([]byte(`{
"scope": "all",
"summary": {"total_clicks": 100, "unique_clicks": 60, "avg_redirection_time": 0.12},
Expand All @@ -27,7 +30,7 @@ func TestStatsQueryAndDecode(t *testing.T) {

c := New(srv.URL, newTestStore(t, nil))
res, err := c.Stats(context.Background(), StatsQuery{
Scope: "all", GroupBy: []string{"time", "browser"}, Timezone: "UTC",
GroupBy: []string{"time", "browser"}, Timezone: "UTC",
})
if err != nil {
t.Fatal(err)
Expand All @@ -44,22 +47,105 @@ func TestStatsQueryAndDecode(t *testing.T) {
}
}

func TestLinkStatsHitsPerLinkPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/stats/links/65f0abc123" {
t.Errorf("path = %s", r.URL.Path)
}
if q := r.URL.Query(); q.Has("scope") || q.Has("short_code") {
t.Errorf("legacy params must not be sent: %v", q)
}
w.Write([]byte(`{
"url_id": "65f0abc123", "alias": "launch", "scope": "all",
"summary": {"total_clicks": 42, "unique_clicks": 30},
"metrics": {}
}`))
}))
defer srv.Close()

c := New(srv.URL, newTestStore(t, nil))
res, err := c.LinkStats(context.Background(), "65f0abc123", StatsQuery{GroupBy: []string{"time"}})
if err != nil {
t.Fatal(err)
}
if res.URLID != "65f0abc123" || res.Alias != "launch" || res.Summary.TotalClicks != 42 {
t.Fatalf("res = %+v", res)
}
}

func TestPublicStatsUnwrapsEnvelope(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/public/stats/launch" {
t.Errorf("path = %s", r.URL.Path)
}
q := r.URL.Query()
if q.Get("start_date") != "2026-01-01T00:00:00Z" || q.Get("timezone") != "UTC" {
t.Errorf("unexpected query: %v", q)
}
if q.Has("group_by") || q.Has("scope") {
t.Errorf("public endpoint takes only a range and timezone: %v", q)
}
w.Write([]byte(`{
"generation": "v2",
"link": {"alias": "launch", "domain": "spoo.me"},
"stats": {
"scope": "anon",
"summary": {"total_clicks": 9, "unique_clicks": 5},
"metrics": {"clicks_by_browser": [{"browser": "Chrome", "clicks": 9}]}
}
}`))
}))
defer srv.Close()

c := New(srv.URL, newTestStore(t, nil))
res, err := c.PublicStats(context.Background(), "launch", "2026-01-01T00:00:00Z", "", "UTC")
if err != nil {
t.Fatal(err)
}
if res.Summary.TotalClicks != 9 || len(res.Metrics["clicks_by_browser"]) != 1 {
t.Fatalf("res = %+v", res)
}
}

func TestExportReturnsFilenameAndBytes(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/export" || r.URL.Query().Get("format") != "xlsx" {
t.Errorf("unexpected request: %s %v", r.URL.Path, r.URL.Query())
}
if r.URL.Query().Has("scope") {
t.Errorf("scope param must not be sent: %v", r.URL.Query())
}
w.Header().Set("Content-Disposition", `attachment; filename="stats-launch.xlsx"`)
w.Write([]byte("FAKEXLSX"))
}))
defer srv.Close()

c := New(srv.URL, newTestStore(t, nil))
name, data, err := c.Export(context.Background(), StatsQuery{ShortCode: "launch"}, "xlsx")
name, data, err := c.Export(context.Background(), StatsQuery{}, "xlsx")
if err != nil {
t.Fatal(err)
}
if name != "stats-launch.xlsx" || string(data) != "FAKEXLSX" {
t.Fatalf("name=%q data=%q", name, data)
}
}

func TestExportLinkHitsPerLinkPath(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/export/links/65f0abc123" {
t.Errorf("path = %s", r.URL.Path)
}
w.Header().Set("Content-Disposition", `attachment; filename="stats-launch.zip"`)
w.Write([]byte("FAKEZIP"))
}))
defer srv.Close()

c := New(srv.URL, newTestStore(t, nil))
name, data, err := c.ExportLink(context.Background(), "65f0abc123", StatsQuery{}, "csv")
if err != nil {
t.Fatal(err)
}
if name != "stats-launch.zip" || string(data) != "FAKEZIP" {
t.Fatalf("name=%q data=%q", name, data)
}
}
22 changes: 22 additions & 0 deletions internal/api/urls.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,28 @@ func (c *Client) ListURLs(ctx context.Context, opts ListURLsOptions) (*URLPage,
return &out, nil
}

// ResolveAlias looks up an owned link by alias via GET
// /api/v1/urls/{domain}/{alias}, mainly to obtain its url id for the
// per-link stats and export endpoints. An empty domain defaults to the
// API base URL's hostname, which covers spoo.me links; pass one of the
// user's custom domains to resolve links living there. Unknown and
// foreign aliases both answer 404 (no ownership oracle).
func (c *Client) ResolveAlias(ctx context.Context, alias, domain string) (*URLItem, error) {
if domain == "" {
base, err := url.Parse(c.base)
if err != nil {
return nil, err
}
domain = base.Hostname()
}
path := "/api/v1/urls/" + url.PathEscape(domain) + "/" + url.PathEscape(alias)
var out URLItem
if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
return nil, err
}
return &out, nil
}

// UpdatedURL mirrors UpdateUrlResponse — unlike the shorten response it
// carries no short_url, and timestamps are Unix seconds.
type UpdatedURL struct {
Expand Down
Loading
Loading