Skip to content

Commit 321dfa6

Browse files
committed
feat: status endpoint, password stats, domain sweep, derived short URLs
- SetURLStatus hits PATCH /urls/{id}/status and returns the same UpdatedURL shape as UpdateURL. - PublicStats now returns the full envelope (PublicStatsResult with PublicLinkFacts + stats) instead of discarding the link half, and PublicStatsQuery.Password routes the call as a POST with the password in the JSON body, the only channel the API reads (query-string passwords are ignored by design). Wrong passwords keep the typed ErrLinkPasswordProtected semantics. - DeleteURLsByDomain sweeps one owned custom domain; the server refuses the system default domain. - URLItem.ShortURL is derived client-side on list/get/resolve from alias + domain, falling back to the client base URL.
1 parent 7672103 commit 321dfa6

5 files changed

Lines changed: 274 additions & 19 deletions

File tree

README.md

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -126,8 +126,15 @@ stats, err := client.Stats(ctx, spoo.StatsQuery{
126126
// one link, addressed by alias
127127
stats, err = client.StatsByAlias(ctx, "launch", "spoo.me", spoo.StatsQuery{})
128128

129-
// anyone's public stats, no auth
130-
stats, err = client.PublicStats(ctx, "launch", spoo.PublicStatsQuery{})
129+
// anyone's public stats, no auth; the result pairs link facts with stats
130+
public, err := client.PublicStats(ctx, "launch", spoo.PublicStatsQuery{})
131+
fmt.Println(public.Link.Status, public.Stats.Summary.TotalClicks)
132+
133+
// password-protected stats: the password travels in a POST body, never
134+
// the query string
135+
public, err = client.PublicStats(ctx, "secret", spoo.PublicStatsQuery{
136+
Password: "the-link-password",
137+
})
131138
```
132139

133140
Without explicit dates the API returns only the last 7 days; request up to
@@ -231,11 +238,12 @@ file, or database, and rotated tokens persist through it.
231238
| `Shorten`, `CheckAlias` | `POST /api/v1/shorten`, `GET /api/v1/shorten/check-alias` |
232239
| `ListURLs`, `ListURLsAll` | `GET /api/v1/urls` |
233240
| `GetURL`, `ResolveAlias` | `GET /api/v1/urls/{id}`, `GET /api/v1/urls/{domain}/{alias}` |
234-
| `UpdateURL`, `DeleteURL` | `PATCH /api/v1/urls/{id}`, `DELETE /api/v1/urls/{id}` |
241+
| `UpdateURL`, `SetURLStatus` | `PATCH /api/v1/urls/{id}`, `PATCH /api/v1/urls/{id}/status` |
242+
| `DeleteURL`, `DeleteURLsByDomain` | `DELETE /api/v1/urls/{id}`, `DELETE /api/v1/urls?domain=` |
235243
| `ClaimURLs` | `POST /api/v1/urls/claim` |
236244
| `BulkDelete`, `BulkUpdateStatus`, `BulkUpdateExpiry`, `BulkMoveDomain` | `POST /api/v1/urls/bulk/*` |
237245
| `Stats`, `LinkStats`, `StatsByAlias` | `GET /api/v1/stats`, `GET /api/v1/stats/links/{id}` |
238-
| `PublicStats`, `PublicPreview` | `GET /api/v1/public/stats/{code}`, `GET /api/v1/public/preview/{code}` |
246+
| `PublicStats`, `PublicPreview` | `GET or POST /api/v1/public/stats/{code}`, `GET /api/v1/public/preview/{code}` |
239247
| `Export`, `ExportLink` | `GET /api/v1/export` |
240248
| `EmojiSet` | `GET /api/v1/emoji-set` (ETag-cached) |
241249
| `Me` | `GET /auth/me` |

stats.go

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -148,13 +148,40 @@ type PublicStatsQuery struct {
148148
StartDate time.Time
149149
EndDate time.Time
150150
Timezone string // IANA name
151+
// Password unlocks a password-protected link's stats. When set,
152+
// the request goes as a POST with the password in the JSON body:
153+
// the body is the only channel the API reads (query-string
154+
// passwords are ignored so they cannot land in URLs, logs, or
155+
// referrers). A wrong password answers 401 invalid_password,
156+
// surfaced as ErrLinkPasswordProtected.
157+
Password string
158+
}
159+
160+
// PublicLinkFacts is the link half of the public stats envelope: what
161+
// the link is, alongside how it performs.
162+
type PublicLinkFacts struct {
163+
Alias string `json:"alias"`
164+
ShortURL string `json:"short_url"`
165+
// LongURL is withheld (empty) when the link is not active.
166+
LongURL string `json:"long_url"`
167+
CreatedAt Timestamp `json:"created_at"`
168+
Status string `json:"status"` // active | inactive | expired | blocked (lowercase)
169+
MaxClicks *int `json:"max_clicks"`
170+
BlockBots bool `json:"block_bots"`
171+
PasswordProtected bool `json:"password_protected"`
172+
}
173+
174+
// PublicStatsResult is the full public stats envelope.
175+
type PublicStatsResult struct {
176+
Generation string `json:"generation"` // v1 | v2
177+
Link PublicLinkFacts `json:"link"`
178+
Stats StatsResponse `json:"stats"`
151179
}
152180

153181
// PublicStats returns anyone's per-link stats without auth. Private
154-
// links 404 and password-protected ones 401 (ErrLinkPasswordProtected).
155-
// The {generation, link, stats} envelope is unwrapped to the standard
156-
// stats wire.
157-
func (c *Client) PublicStats(ctx context.Context, shortCode string, q PublicStatsQuery) (*StatsResponse, error) {
182+
// links 404; password-protected ones 401 (ErrLinkPasswordProtected)
183+
// unless the query carries the link password.
184+
func (c *Client) PublicStats(ctx context.Context, shortCode string, q PublicStatsQuery) (*PublicStatsResult, error) {
158185
v := url.Values{}
159186
if !q.StartDate.IsZero() {
160187
v.Set("start_date", q.StartDate.UTC().Format(time.RFC3339))
@@ -165,12 +192,14 @@ func (c *Client) PublicStats(ctx context.Context, shortCode string, q PublicStat
165192
if q.Timezone != "" {
166193
v.Set("timezone", q.Timezone)
167194
}
168-
var out struct {
169-
Stats StatsResponse `json:"stats"`
195+
method, body := http.MethodGet, any(nil)
196+
if q.Password != "" {
197+
method, body = http.MethodPost, map[string]string{"password": q.Password}
170198
}
199+
var out PublicStatsResult
171200
path := "/api/v1/public/stats/" + url.PathEscape(shortCode)
172-
if err := c.do(ctx, http.MethodGet, path, v, nil, &out); err != nil {
201+
if err := c.do(ctx, method, path, v, body, &out); err != nil {
173202
return nil, err
174203
}
175-
return &out.Stats, nil
204+
return &out, nil
176205
}

stats_test.go

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -113,21 +113,24 @@ func TestStatsByAlias(t *testing.T) {
113113
}
114114
}
115115

116-
func TestPublicStatsUnwrapsEnvelope(t *testing.T) {
116+
func TestPublicStatsReturnsEnvelope(t *testing.T) {
117117
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
118118
if r.URL.Path != "/api/v1/public/stats/launch" {
119119
t.Errorf("path = %s", r.URL.Path)
120120
}
121+
if r.Method != http.MethodGet {
122+
t.Errorf("method = %s, want GET without a password", r.Method)
123+
}
121124
q := r.URL.Query()
122125
if q.Get("start_date") != "2026-01-01T00:00:00Z" || q.Get("timezone") != "UTC" {
123126
t.Errorf("unexpected query: %v", q)
124127
}
125-
if q.Has("group_by") || q.Has("scope") {
128+
if q.Has("group_by") || q.Has("scope") || q.Has("password") {
126129
t.Errorf("public endpoint takes only a range and timezone: %v", q)
127130
}
128131
w.Write([]byte(`{
129132
"generation": "v2",
130-
"link": {"alias": "launch", "domain": "spoo.me"},
133+
"link": {"alias": "launch", "short_url": "https://spoo.me/launch", "long_url": "https://example.com/x", "status": "active", "password_protected": false, "block_bots": true},
131134
"stats": {
132135
"scope": "anon",
133136
"summary": {"total_clicks": 9, "unique_clicks": 5},
@@ -145,11 +148,72 @@ func TestPublicStatsUnwrapsEnvelope(t *testing.T) {
145148
if err != nil {
146149
t.Fatal(err)
147150
}
148-
if res.Summary.TotalClicks != 9 || len(res.Metrics["clicks_by_browser"]) != 1 {
151+
if res.Stats.Summary.TotalClicks != 9 || len(res.Stats.Metrics["clicks_by_browser"]) != 1 {
152+
t.Fatalf("stats = %+v", res.Stats)
153+
}
154+
link := res.Link
155+
if link.Alias != "launch" || link.ShortURL != "https://spoo.me/launch" || link.Status != "active" || !link.BlockBots {
156+
t.Fatalf("link facts = %+v", link)
157+
}
158+
if res.Generation != "v2" {
159+
t.Fatalf("generation = %q", res.Generation)
160+
}
161+
}
162+
163+
// A password rides in a POST body, never the query string (the API
164+
// ignores query-string passwords so they cannot land in URLs or logs).
165+
func TestPublicStatsPasswordGoesInPOSTBody(t *testing.T) {
166+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
167+
if r.Method != http.MethodPost {
168+
t.Errorf("method = %s, want POST with a password", r.Method)
169+
}
170+
if r.URL.Query().Has("password") {
171+
t.Errorf("password leaked into the query string: %v", r.URL.Query())
172+
}
173+
if r.URL.Query().Get("timezone") != "UTC" {
174+
t.Errorf("query params must still ride the URL: %v", r.URL.Query())
175+
}
176+
body, _ := io.ReadAll(r.Body)
177+
if string(body) != `{"password":"hunter22"}` {
178+
t.Errorf("body = %s", body)
179+
}
180+
w.Write([]byte(`{
181+
"generation": "v2",
182+
"link": {"alias": "secret", "short_url": "https://spoo.me/secret", "status": "active", "password_protected": true, "block_bots": false},
183+
"stats": {"summary": {"total_clicks": 3, "unique_clicks": 2}, "metrics": {}}
184+
}`))
185+
}))
186+
defer srv.Close()
187+
188+
c := NewClient(WithBaseURL(srv.URL))
189+
res, err := c.PublicStats(context.Background(), "secret", PublicStatsQuery{
190+
Timezone: "UTC",
191+
Password: "hunter22",
192+
})
193+
if err != nil {
194+
t.Fatal(err)
195+
}
196+
if !res.Link.PasswordProtected || res.Stats.Summary.TotalClicks != 3 {
149197
t.Fatalf("res = %+v", res)
150198
}
151199
}
152200

201+
// A wrong password keeps the typed 401 semantics.
202+
func TestPublicStatsWrongPassword(t *testing.T) {
203+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
204+
w.Header().Set("X-Error-Code", "invalid_password")
205+
w.WriteHeader(http.StatusUnauthorized)
206+
w.Write([]byte(`{"error":"Invalid password","code":"invalid_password"}`))
207+
}))
208+
defer srv.Close()
209+
210+
c := NewClient(WithBaseURL(srv.URL))
211+
_, err := c.PublicStats(context.Background(), "secret", PublicStatsQuery{Password: "wrong"})
212+
if !errors.Is(err, ErrLinkPasswordProtected) {
213+
t.Fatalf("err = %v, want ErrLinkPasswordProtected", err)
214+
}
215+
}
216+
153217
func TestExportStreamsFilenameAndBody(t *testing.T) {
154218
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155219
if r.URL.Path != "/api/v1/export" || r.URL.Query().Get("format") != "xlsx" {

urls.go

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@ import (
1717
// seconds and created_at/last_click as ISO strings — all normalized to
1818
// [Timestamp].
1919
type URLItem struct {
20-
ID string `json:"id"`
21-
Alias string `json:"alias"`
22-
LongURL string `json:"long_url"`
20+
ID string `json:"id"`
21+
Alias string `json:"alias"`
22+
LongURL string `json:"long_url"`
23+
// ShortURL is derived client-side (the management wire carries no
24+
// short_url): https://<Domain>/<Alias> when the link lives on a
25+
// custom domain, else the client's base URL plus the alias. Empty
26+
// when Alias is empty. Emoji aliases appear unencoded, matching
27+
// the shorten response.
28+
ShortURL string `json:"-"`
2329
CreatedAt Timestamp `json:"created_at"`
2430
LastClick Timestamp `json:"last_click"`
2531
TotalClicks int `json:"total_clicks"`
@@ -93,9 +99,23 @@ func (c *Client) ListURLs(ctx context.Context, opts ListURLsOptions) (*URLPage,
9399
if err := c.do(ctx, http.MethodGet, "/api/v1/urls", q, nil, &out); err != nil {
94100
return nil, err
95101
}
102+
for i := range out.Items {
103+
out.Items[i].ShortURL = c.shortURLFor(out.Items[i])
104+
}
96105
return &out, nil
97106
}
98107

108+
// shortURLFor derives an item's public short URL; see URLItem.ShortURL.
109+
func (c *Client) shortURLFor(item URLItem) string {
110+
if item.Alias == "" {
111+
return ""
112+
}
113+
if item.Domain != "" {
114+
return "https://" + item.Domain + "/" + item.Alias
115+
}
116+
return c.base + "/" + item.Alias
117+
}
118+
99119
// ListURLsAll pages through every link matching opts, lazily fetching
100120
// pages as the caller ranges. opts.Page picks the starting page (1 when
101121
// zero); on a fetch error the iterator yields it once and stops:
@@ -137,6 +157,7 @@ func (c *Client) GetURL(ctx context.Context, id string) (*URLItem, error) {
137157
if err := c.do(ctx, http.MethodGet, "/api/v1/urls/"+url.PathEscape(id), nil, nil, &out); err != nil {
138158
return nil, err
139159
}
160+
out.ShortURL = c.shortURLFor(out)
140161
return &out, nil
141162
}
142163

@@ -157,6 +178,7 @@ func (c *Client) ResolveAlias(ctx context.Context, alias, domain string) (*URLIt
157178
if err := c.do(ctx, http.MethodGet, path, nil, nil, &out); err != nil {
158179
return nil, err
159180
}
181+
out.ShortURL = c.shortURLFor(out)
160182
return &out, nil
161183
}
162184

@@ -204,7 +226,42 @@ func (c *Client) UpdateURL(ctx context.Context, id string, params UpdateURLParam
204226
return &out, nil
205227
}
206228

229+
// SetURLStatus flips one owned link between ACTIVE and INACTIVE via
230+
// the dedicated status endpoint. BLOCKED and EXPIRED are server-owned
231+
// states and not caller-settable.
232+
func (c *Client) SetURLStatus(ctx context.Context, id, status string) (*UpdatedURL, error) {
233+
body := struct {
234+
Status string `json:"status"`
235+
}{Status: status}
236+
var out UpdatedURL
237+
path := "/api/v1/urls/" + url.PathEscape(id) + "/status"
238+
if err := c.do(ctx, http.MethodPatch, path, nil, body, &out); err != nil {
239+
return nil, err
240+
}
241+
return &out, nil
242+
}
243+
207244
// DeleteURL permanently deletes one owned link by its url id.
208245
func (c *Client) DeleteURL(ctx context.Context, id string) error {
209246
return c.do(ctx, http.MethodDelete, "/api/v1/urls/"+url.PathEscape(id), nil, nil, nil)
210247
}
248+
249+
// DomainDeletion reports a delete-by-domain sweep.
250+
type DomainDeletion struct {
251+
Message string `json:"message"`
252+
Count int `json:"count"`
253+
Domain string `json:"domain"`
254+
}
255+
256+
// DeleteURLsByDomain deletes every link the account owns on the given
257+
// custom domain. The server refuses the system default domain, so one
258+
// call can never wipe the account's spoo.me inventory; the caller must
259+
// own the domain.
260+
func (c *Client) DeleteURLsByDomain(ctx context.Context, domain string) (*DomainDeletion, error) {
261+
q := url.Values{"domain": {domain}}
262+
var out DomainDeletion
263+
if err := c.do(ctx, http.MethodDelete, "/api/v1/urls", q, nil, &out); err != nil {
264+
return nil, err
265+
}
266+
return &out, nil
267+
}

0 commit comments

Comments
 (0)