Skip to content

Commit 73f7ba3

Browse files
committed
feat: link tags
Tags went live on the API in spoo v2.4.0 and this SDK had no way to reach them. Adds the tag endpoints, tag_ids on shorten and update, tags on every link response, the tagIds/tagNames/tagsMatch list filters, the tag and tag_id stats and export filters, and BulkUpdateTags next to the other bulk methods. Refreshes openapi.json so spec drift stays green.
1 parent 646a22a commit 73f7ba3

14 files changed

Lines changed: 2839 additions & 441 deletions

README.md

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,56 @@ for _, row := range res.Results {
115115
}
116116
```
117117

118-
`BulkDelete`, `BulkUpdateExpiry`, and `BulkMoveDomain` follow the same shape.
118+
`BulkDelete`, `BulkUpdateExpiry`, `BulkMoveDomain`, and `BulkUpdateTags` follow
119+
the same shape.
120+
121+
## Tags
122+
123+
Tags are account-wide labels with a color and an icon. A link carries up to
124+
ten of them, by id:
125+
126+
```go
127+
tag, err := client.CreateTag(ctx, spoo.CreateTagParams{Name: "launch", Color: "violet"})
128+
129+
link, err := client.Shorten(ctx, spoo.ShortenRequest{
130+
LongURL: "https://example.com/launch",
131+
TagIDs: []string{tag.ID},
132+
})
133+
134+
// on update the list replaces the stored one; spoo.Null clears it
135+
_, err = client.UpdateURL(ctx, link.ID, spoo.UpdateURLParams{
136+
TagIDs: spoo.Set([]string{tag.ID}),
137+
})
138+
```
139+
140+
Filter the link list by tag id or name, and pick whether a link needs any or
141+
all of them:
142+
143+
```go
144+
page, err := client.ListURLs(ctx, spoo.ListURLsOptions{
145+
TagNames: []string{"launch", "q3"},
146+
TagsMatch: "all",
147+
})
148+
```
149+
150+
Tag or untag up to 100 links at once; the result has the same per-item shape
151+
as the other bulk operations:
152+
153+
```go
154+
res, err := client.BulkUpdateTags(ctx, ids, spoo.BulkTagChange{Add: []string{tag.ID}})
155+
```
156+
157+
Stats and exports take `tag` (names) and `tag_id` filters on the aggregate
158+
routes, covering the whole click history of the tagged links:
159+
160+
```go
161+
stats, err := client.Stats(ctx, spoo.StatsQuery{
162+
Filters: map[string][]string{"tag": {"launch"}},
163+
})
164+
```
165+
166+
`ListTags`, `UpdateTag`, and `DeleteTag` round out the set. Deleting a tag
167+
removes it from every link and reports how many were touched.
119168

120169
## Stats and exports
121170

@@ -243,8 +292,9 @@ file, or database, and rotated tokens persist through it.
243292
## Scope
244293

245294
The SDK covers the v1 data plane end to end: shortening, link management,
246-
claiming, bulk operations, stats, exports, public stats and previews, the
247-
emoji alias policy, identity (`Me`), and the Sign in with Spoo device flow.
295+
claiming, tags, bulk operations, stats, exports, public stats and previews,
296+
the emoji alias policy, identity (`Me`), and the Sign in with Spoo device
297+
flow.
248298
Deliberately out of scope: API key management, health checks, the contact
249299
endpoint, profile management, and all legacy v0 routes. Anything the API
250300
grows before the SDK does is reachable through the raw request methods
@@ -260,7 +310,9 @@ below.
260310
| `UpdateURL`, `SetURLStatus` | `PATCH /api/v1/urls/{id}`, `PATCH /api/v1/urls/{id}/status` |
261311
| `DeleteURL`, `DeleteURLsByDomain` | `DELETE /api/v1/urls/{id}`, `DELETE /api/v1/urls?domain=` |
262312
| `ClaimURLs` | `POST /api/v1/urls/claim` |
263-
| `BulkDelete`, `BulkUpdateStatus`, `BulkUpdateExpiry`, `BulkMoveDomain` | `POST /api/v1/urls/bulk/*` |
313+
| `BulkDelete`, `BulkUpdateStatus`, `BulkUpdateExpiry`, `BulkMoveDomain`, `BulkUpdateTags` | `POST /api/v1/urls/bulk/*` |
314+
| `ListTags`, `CreateTag` | `GET /api/v1/tags`, `POST /api/v1/tags` |
315+
| `UpdateTag`, `DeleteTag` | `PATCH /api/v1/tags/{id}`, `DELETE /api/v1/tags/{id}` |
264316
| `Stats`, `LinkStats`, `StatsByAlias` | `GET /api/v1/stats`, `GET /api/v1/stats/links/{id}` |
265317
| `PublicStats`, `PublicPreview` | `GET or POST /api/v1/public/stats/{code}`, `GET /api/v1/public/preview/{code}` |
266318
| `Export`, `ExportLink` | `GET /api/v1/export`, `GET /api/v1/export/links/{id}` |

bulk.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,27 @@ func (c *Client) BulkMoveDomain(ctx context.Context, ids []string, domain string
7979
return c.bulk(ctx, "/api/v1/urls/bulk/domain", body)
8080
}
8181

82+
// BulkTagChange names the tag ids to add to and remove from every link
83+
// in a [Client.BulkUpdateTags] call. At least one side must name a tag,
84+
// and no tag may appear on both.
85+
type BulkTagChange struct {
86+
Add []string `json:"add,omitempty"`
87+
Remove []string `json:"remove,omitempty"`
88+
}
89+
90+
// BulkUpdateTags adds and removes tags, by tag id, on up to 100 owned
91+
// links by url id. Each link ends up with its current tags minus Remove
92+
// plus Add (kept once, order preserved). An unknown id in Add rejects
93+
// the whole request; a link that would exceed 10 tags fails per-item
94+
// with validation_error.
95+
func (c *Client) BulkUpdateTags(ctx context.Context, ids []string, change BulkTagChange) (*BulkResult, error) {
96+
body := struct {
97+
IDs []string `json:"ids"`
98+
BulkTagChange
99+
}{IDs: ids, BulkTagChange: change}
100+
return c.bulk(ctx, "/api/v1/urls/bulk/tags", body)
101+
}
102+
82103
func (c *Client) bulk(ctx context.Context, path string, body any) (*BulkResult, error) {
83104
var out BulkResult
84105
if err := c.do(ctx, http.MethodPost, path, nil, body, &out); err != nil {

bulk_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,36 @@ func TestBulkMoveDomainWire(t *testing.T) {
122122
t.Fatalf("clear body = %s", bodies[1])
123123
}
124124
}
125+
126+
// Empty add or remove lists are omitted; the server requires at least
127+
// one of them to name a tag.
128+
func TestBulkUpdateTagsBody(t *testing.T) {
129+
var bodies []string
130+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131+
if r.URL.Path != "/api/v1/urls/bulk/tags" || r.Method != http.MethodPost {
132+
t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
133+
}
134+
data, _ := io.ReadAll(r.Body)
135+
bodies = append(bodies, string(data))
136+
w.Write([]byte(`{"summary":{"total":2,"succeeded":1,"failed":1},"results":[{"id":"a","alias":"x","ok":true},{"id":"b","alias":"y","ok":false,"error_code":"validation_error","error":"too many tags"}]}`))
137+
}))
138+
defer srv.Close()
139+
140+
c := NewClient(option.WithBaseURL(srv.URL))
141+
res, err := c.BulkUpdateTags(context.Background(), []string{"a", "b"}, BulkTagChange{Add: []string{"t1", "t2"}, Remove: []string{"t3"}})
142+
if err != nil {
143+
t.Fatal(err)
144+
}
145+
if _, err := c.BulkUpdateTags(context.Background(), []string{"a", "b"}, BulkTagChange{Remove: []string{"t3"}}); err != nil {
146+
t.Fatal(err)
147+
}
148+
if bodies[0] != `{"ids":["a","b"],"add":["t1","t2"],"remove":["t3"]}` {
149+
t.Fatalf("body = %s", bodies[0])
150+
}
151+
if bodies[1] != `{"ids":["a","b"],"remove":["t3"]}` {
152+
t.Fatalf("remove-only body = %s", bodies[1])
153+
}
154+
if res.Summary.Failed != 1 || res.Results[1].ErrorCode != "validation_error" {
155+
t.Fatalf("res = %+v", res)
156+
}
157+
}

doc.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
// API.
33
//
44
// The package covers the v1 HTTP API: shortening, link management,
5-
// claiming, bulk operations, stats, exports, public previews, the emoji
6-
// alias policy, and the connected-apps device flow. It has no
5+
// claiming, tags, bulk operations, stats, exports, public previews, the
6+
// emoji alias policy, and the connected-apps device flow. It has no
77
// dependencies outside the standard library. Endpoints without a typed
88
// method yet are reachable through the raw [Client.Get], [Client.Post],
99
// [Client.Put], [Client.Patch] and [Client.Delete] passthroughs, which

export.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ type ExportFile struct {
2727

2828
// Export downloads account-wide stats in the given format (json, csv,
2929
// xlsx, xml). Auth is required — anonymous export no longer exists.
30-
// Slice to specific links with the short_code / url_id filters on
31-
// [StatsQuery]; note the aggregate route reports a generic filename
32-
// regardless of slicing, so single-link exports belong on ExportLink.
30+
// Slice to specific links with the short_code / url_id / tag / tag_id
31+
// filters on [StatsQuery]; note the aggregate route reports a generic
32+
// filename regardless of slicing, so single-link exports belong on
33+
// ExportLink.
3334
func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (*ExportFile, error) {
3435
return c.export(ctx, "/api/v1/export", q.values(), format)
3536
}
@@ -39,7 +40,8 @@ func (c *Client) Export(ctx context.Context, q StatsQuery, format string) (*Expo
3940
// identity (the aggregate route names every download the same, so
4041
// saved files would silently overwrite each other). Resolve an alias
4142
// with ResolveAlias first; unknown and foreign ids both 404. The
42-
// short_code / url_id slicing filters are aggregate-only here too.
43+
// short_code / url_id / tag / tag_id slicing filters are not accepted on
44+
// the per-link routes and are rejected client-side here too.
4345
func (c *Client) ExportLink(ctx context.Context, urlID string, q StatsQuery, format string) (*ExportFile, error) {
4446
if err := q.validatePerLink(); err != nil {
4547
return nil, err

0 commit comments

Comments
 (0)