Refactor cursor code to DRY it out

This commit is contained in:
Jonathon Leight
2026-07-02 19:17:06 -04:00
parent a422c3625f
commit 9faf6e01d8
4 changed files with 95 additions and 42 deletions
+3 -13
View File
@@ -1,8 +1,6 @@
package core
import (
"encoding/base64"
"encoding/json"
"net/http"
"time"
@@ -42,22 +40,14 @@ type logCursor struct {
// encodeLogCursor packs a session position into an opaque, URL-safe token.
func encodeLogCursor(startedAt time.Time, id int64) string {
b, _ := json.Marshal(logCursor{StartedAt: startedAt, ID: id})
return base64.RawURLEncoding.EncodeToString(b)
return web.EncodeCursor(logCursor{StartedAt: startedAt, ID: id})
}
// decodeLogCursor reverses encodeLogCursor. A missing or malformed token decodes
// to nil — the first page — so a tampered URL just resets paging.
func decodeLogCursor(tok string) *store.CommandLogCursor {
if tok == "" {
return nil
}
b, err := base64.RawURLEncoding.DecodeString(tok)
if err != nil {
return nil
}
var c logCursor
if json.Unmarshal(b, &c) != nil {
c, ok := web.DecodeCursor[logCursor](tok)
if !ok {
return nil
}
return &store.CommandLogCursor{StartedAt: c.StartedAt, ID: c.ID}
+4 -29
View File
@@ -1,8 +1,6 @@
package marketing
import (
"encoding/base64"
"encoding/json"
"net/http"
"strings"
"time"
@@ -178,24 +176,13 @@ type repCursor struct {
}
func encodeRepCursor(name string, id int64) string {
b, _ := json.Marshal(repCursor{Name: name, ID: id})
return base64.RawURLEncoding.EncodeToString(b)
return web.EncodeCursor(repCursor{Name: name, ID: id})
}
// decodeRepCursor reverses encodeRepCursor; a missing or malformed cursor decodes
// to the first page ("", 0).
func decodeRepCursor(tok string) (name string, id int64) {
if tok == "" {
return "", 0
}
b, err := base64.RawURLEncoding.DecodeString(tok)
if err != nil {
return "", 0
}
var c repCursor
if json.Unmarshal(b, &c) != nil {
return "", 0
}
c, _ := web.DecodeCursor[repCursor](tok)
return c.Name, c.ID
}
@@ -229,23 +216,11 @@ func nextOrgCursor(sort store.OrgSort, query string, last store.OrgSummary) orgC
// encodeOrgCursor packs a directory position into an opaque, URL-safe token.
func encodeOrgCursor(c orgCursor) string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
return web.EncodeCursor(c)
}
// decodeOrgCursor reverses encodeOrgCursor. A missing or malformed cursor decodes
// to ok=false, i.e. the first page.
func decodeOrgCursor(tok string) (orgCursor, bool) {
if tok == "" {
return orgCursor{}, false
}
b, err := base64.RawURLEncoding.DecodeString(tok)
if err != nil {
return orgCursor{}, false
}
var c orgCursor
if json.Unmarshal(b, &c) != nil {
return orgCursor{}, false
}
return c, true
return web.DecodeCursor[orgCursor](tok)
}
+32
View File
@@ -0,0 +1,32 @@
package web
import (
"encoding/base64"
"encoding/json"
)
// EncodeCursor packs a keyset position into an opaque, URL-safe token. Callers
// pass a small named struct with short JSON tags (the wire form is not stable
// API — it only round-trips through the client's ?cursor/?before parameter).
func EncodeCursor[T any](pos T) string {
b, _ := json.Marshal(pos)
return base64.RawURLEncoding.EncodeToString(b)
}
// DecodeCursor reverses EncodeCursor. A missing or malformed token decodes to the
// zero value with ok=false, so a tampered or stale URL just resets paging to the
// first page rather than erroring.
func DecodeCursor[T any](tok string) (pos T, ok bool) {
if tok == "" {
return pos, false
}
b, err := base64.RawURLEncoding.DecodeString(tok)
if err != nil {
return pos, false
}
if json.Unmarshal(b, &pos) != nil {
var zero T
return zero, false
}
return pos, true
}
+56
View File
@@ -0,0 +1,56 @@
package web
import (
"testing"
"time"
)
type testCursor struct {
Name string `json:"n"`
ID int64 `json:"i"`
At time.Time `json:"t"`
}
func TestCursorRoundTrip(t *testing.T) {
want := testCursor{Name: "repeater-alpha", ID: 42, At: time.Unix(1_700_000_000, 0).UTC()}
got, ok := DecodeCursor[testCursor](EncodeCursor(want))
if !ok {
t.Fatal("DecodeCursor: ok=false for a token we just encoded")
}
if got.Name != want.Name || got.ID != want.ID || !got.At.Equal(want.At) {
t.Fatalf("round trip mismatch: got %+v want %+v", got, want)
}
}
func TestCursorTokenIsURLSafe(t *testing.T) {
// RawURLEncoding must not emit '+', '/', or '=' — the token rides in a query
// parameter unescaped.
tok := EncodeCursor(testCursor{Name: "a/b+c==", ID: 1})
for _, r := range tok {
switch r {
case '+', '/', '=':
t.Fatalf("token %q contains non-URL-safe rune %q", tok, r)
}
}
}
func TestDecodeCursorRejectsBadInput(t *testing.T) {
// A missing, malformed-base64, or malformed-JSON token must reset to the zero
// value with ok=false so a tampered URL just pages from the start.
cases := map[string]string{
"empty": "",
"not base64": "!!!not base64!!!",
"base64 non-json": EncodeCursor([]byte("plain string, not this struct's JSON")),
}
for name, tok := range cases {
t.Run(name, func(t *testing.T) {
got, ok := DecodeCursor[testCursor](tok)
if ok {
t.Fatalf("ok=true for %s input %q", name, tok)
}
if got != (testCursor{}) {
t.Fatalf("non-zero cursor %+v for %s input", got, name)
}
})
}
}