Skip to content

Commit f7480ab

Browse files
pbennettjannotti
andauthored
API: Add cursor-based pagination with prefix support to application boxes (#6558)
Co-authored-by: John Jannotti <jannotti@gmail.com>
1 parent 8a03211 commit f7480ab

30 files changed

Lines changed: 3430 additions & 1423 deletions

File tree

cmd/goal/box.go

Lines changed: 63 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,16 @@ import (
2121
"strings"
2222

2323
"github.com/spf13/cobra"
24+
25+
"github.com/algorand/go-algorand/data/basics"
2426
)
2527

2628
var boxName string
27-
var maxBoxes uint64
29+
var boxLimit uint64
30+
var boxNext string
31+
var boxPrefix string
32+
var boxValues bool
33+
var boxRound uint64
2834

2935
func init() {
3036
appCmd.AddCommand(appBoxCmd)
@@ -37,7 +43,11 @@ func init() {
3743
appBoxInfoCmd.Flags().StringVarP(&boxName, "name", "n", "", "Application box name. Use the same form as app-arg to name the box.")
3844
appBoxInfoCmd.MarkFlagRequired("name")
3945

40-
appBoxListCmd.Flags().Uint64VarP(&maxBoxes, "max", "m", 0, "Maximum number of boxes to list. 0 means no limit.")
46+
appBoxListCmd.Flags().Uint64VarP(&boxLimit, "limit", "l", 0, "Maximum number of boxes per page (default: 1000, or 100 with --values).")
47+
appBoxListCmd.Flags().StringVarP(&boxNext, "next", "n", "", "Pagination cursor from a previous response's next-token.")
48+
appBoxListCmd.Flags().StringVarP(&boxPrefix, "prefix", "p", "", "Filter by box name prefix, in the same form as app-arg.")
49+
appBoxListCmd.Flags().BoolVarP(&boxValues, "values", "v", false, "If set, include box values in the output.")
50+
appBoxListCmd.Flags().Uint64VarP(&boxRound, "round", "r", 0, "Query boxes at a specific round (auto-pinned from first page if not set).")
4151
}
4252

4353
var appBoxCmd = &cobra.Command{
@@ -90,29 +100,64 @@ var appBoxInfoCmd = &cobra.Command{
90100
var appBoxListCmd = &cobra.Command{
91101
Use: "list",
92102
Short: "List all application boxes belonging to an application",
93-
Long: "List all application boxes belonging to an application.\n" +
94-
"For printable strings, the box name is formatted as 'str:hello'\n" +
95-
"For everything else, the box name is formatted as 'b64:A=='. ",
103+
Long: `List all application boxes belonging to an application.
104+
For printable strings, the box name is formatted as 'str:hello'
105+
For everything else, the box name is formatted as 'b64:A=='.
106+
107+
Use --limit to fetch a single page of results.
108+
When there are more results, next-token is printed.
109+
Use --next and --round to resume from a limited request.
110+
Use --prefix to filter boxes by name prefix.
111+
Use --values to include box values in the output.`,
96112
Args: validateNoPosArgsFn,
97113
Run: func(cmd *cobra.Command, args []string) {
98114
_, client := getDataDirAndClient()
99115

100-
// Get app boxes
101-
boxesRes, err := client.ApplicationBoxes(appIdx, maxBoxes)
102-
if err != nil {
103-
reportErrorf(errorRequestFail, err)
116+
// Apply default limit when not explicitly set.
117+
limit := boxLimit
118+
if limit == 0 {
119+
if boxValues {
120+
limit = 100
121+
} else {
122+
limit = 1000
123+
}
104124
}
105-
boxes := boxesRes.Boxes
106125

107-
// Error if no boxes found
108-
if len(boxes) == 0 {
109-
reportErrorf("No boxes found for appid %d", appIdx)
110-
}
126+
next := boxNext
127+
round := basics.Round(boxRound)
128+
for {
129+
boxesRes, err := client.ApplicationBoxesPage(appIdx, limit, next, boxPrefix, boxValues, round)
130+
if err != nil {
131+
reportErrorf(errorRequestFail, err)
132+
}
111133

112-
// Print app boxes
113-
for _, descriptor := range boxes {
114-
encodedName := encodeBytesAsAppCallBytes(descriptor.Name)
115-
reportInfof("%s", encodedName)
134+
if round == 0 {
135+
// Auto-pin round from first page for consistent pagination.
136+
round = *boxesRes.Round
137+
if len(boxesRes.Boxes) == 0 {
138+
reportErrorf("No matching boxes found")
139+
}
140+
}
141+
142+
for _, descriptor := range boxesRes.Boxes {
143+
encodedName := encodeBytesAsAppCallBytes(descriptor.Name)
144+
if boxValues && descriptor.Value != nil {
145+
encodedValue := encodeBytesAsAppCallBytes(*descriptor.Value)
146+
reportInfof("%s : %s", encodedName, encodedValue)
147+
} else {
148+
reportInfof("%s", encodedName)
149+
}
150+
}
151+
152+
if boxesRes.NextToken == nil || *boxesRes.NextToken == "" {
153+
break
154+
}
155+
next = *boxesRes.NextToken
156+
if boxLimit > 0 || boxNext != "" {
157+
// Stop after a page if a limit or next-token was explicitly specified
158+
reportInfof("next-token: %s", next)
159+
break
160+
}
116161
}
117162
},
118163
}

daemon/algod/api/algod.oas2.json

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1798,7 +1798,7 @@
17981798
},
17991799
"/v2/applications/{application-id}/boxes": {
18001800
"get": {
1801-
"description": "Given an application ID, return all Box names. No particular ordering is guaranteed. Request fails when client or server-side configured limits prevent returning all Box names.",
1801+
"description": "Given an application ID, return all box names. No particular ordering is guaranteed. Request fails when client or server-side configured limits prevent returning all box names.\n\nPagination mode is enabled when any of the following parameters are provided: limit, next, prefix, include, or round. In pagination mode box values can be requested and results are returned in sorted order.\n\nTo paginate: use the next-token from a previous response as the next parameter in the following request. Pin the round parameter to the round value from the first page's response to ensure consistent results across pages. The server enforces a per-response byte limit, so fewer results than limit may be returned even when more exist; the presence of next-token is the only reliable signal that more data is available.",
18021802
"tags": ["public", "nonparticipating"],
18031803
"produces": ["application/json"],
18041804
"schemes": ["http"],
@@ -1814,6 +1814,44 @@
18141814
"description": "Max number of box names to return. If max is not set, or max == 0, returns all box-names.",
18151815
"name": "max",
18161816
"in": "query"
1817+
},
1818+
{
1819+
"type": "integer",
1820+
"x-go-type": "uint64",
1821+
"description": "Maximum number of boxes to return per page.",
1822+
"name": "limit",
1823+
"in": "query"
1824+
},
1825+
{
1826+
"type": "string",
1827+
"description": "A box name, in the goal app call arg form 'encoding:value', representing the earliest box name to include in results. Use the next-token from a previous response.",
1828+
"name": "next",
1829+
"in": "query"
1830+
},
1831+
{
1832+
"type": "string",
1833+
"description": "A box name prefix, in the goal app call arg form 'encoding:value', to filter results by. Only boxes whose names start with this prefix will be returned.",
1834+
"name": "prefix",
1835+
"in": "query"
1836+
},
1837+
{
1838+
"type": "array",
1839+
"items": {
1840+
"type": "string",
1841+
"enum": ["values"]
1842+
},
1843+
"collectionFormat": "csv",
1844+
"description": "Include additional items in the response. Use `values` to include box values. Multiple values can be comma-separated.",
1845+
"name": "include",
1846+
"in": "query"
1847+
},
1848+
{
1849+
"type": "integer",
1850+
"format": "uint64",
1851+
"x-go-type": "basics.Round",
1852+
"description": "Return box data from the given round. The round must be within the node's available range.",
1853+
"name": "round",
1854+
"in": "query"
18171855
}
18181856
],
18191857
"responses": {
@@ -3519,6 +3557,11 @@
35193557
"description": "Base64 encoded box name",
35203558
"type": "string",
35213559
"format": "byte"
3560+
},
3561+
"value": {
3562+
"description": "Base64 encoded box value. Present only when the `values` query parameter is set to true.",
3563+
"type": "string",
3564+
"format": "byte"
35223565
}
35233566
}
35243567
},
@@ -4872,11 +4915,20 @@
48724915
}
48734916
},
48744917
"BoxesResponse": {
4875-
"description": "Box names of an application",
4918+
"description": "Boxes of an application",
48764919
"schema": {
48774920
"type": "object",
48784921
"required": ["boxes"],
48794922
"properties": {
4923+
"round": {
4924+
"description": "The round for which this information is relevant.",
4925+
"type": "integer",
4926+
"x-go-type": "basics.Round"
4927+
},
4928+
"next-token": {
4929+
"description": "Used for pagination, when making another request provide this token with the next parameter. The next token is the box name to use as the pagination cursor, encoded in the goal app call arg form.",
4930+
"type": "string"
4931+
},
48804932
"boxes": {
48814933
"type": "array",
48824934
"items": {

daemon/algod/api/algod.oas3.yml

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -412,6 +412,15 @@
412412
"$ref": "#/components/schemas/BoxDescriptor"
413413
},
414414
"type": "array"
415+
},
416+
"next-token": {
417+
"description": "Used for pagination, when making another request provide this token with the next parameter. The next token is the box name to use as the pagination cursor, encoded in the goal app call arg form.",
418+
"type": "string"
419+
},
420+
"round": {
421+
"description": "The round for which this information is relevant.",
422+
"type": "integer",
423+
"x-go-type": "basics.Round"
415424
}
416425
},
417426
"required": [
@@ -421,7 +430,7 @@
421430
}
422431
}
423432
},
424-
"description": "Box names of an application"
433+
"description": "Boxes of an application"
425434
},
426435
"CatchpointAbortResponse": {
427436
"content": {
@@ -1779,6 +1788,12 @@
17791788
"format": "byte",
17801789
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
17811790
"type": "string"
1791+
},
1792+
"value": {
1793+
"description": "Base64 encoded box value. Present only when the `values` query parameter is set to true.",
1794+
"format": "byte",
1795+
"pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$",
1796+
"type": "string"
17821797
}
17831798
},
17841799
"required": [
@@ -4159,7 +4174,7 @@
41594174
},
41604175
"/v2/applications/{application-id}/boxes": {
41614176
"get": {
4162-
"description": "Given an application ID, return all Box names. No particular ordering is guaranteed. Request fails when client or server-side configured limits prevent returning all Box names.",
4177+
"description": "Given an application ID, return all box names. No particular ordering is guaranteed. Request fails when client or server-side configured limits prevent returning all box names.\n\nPagination mode is enabled when any of the following parameters are provided: limit, next, prefix, include, or round. In pagination mode box values can be requested and results are returned in sorted order.\n\nTo paginate: use the next-token from a previous response as the next parameter in the following request. Pin the round parameter to the round value from the first page's response to ensure consistent results across pages. The server enforces a per-response byte limit, so fewer results than limit may be returned even when more exist; the presence of next-token is the only reliable signal that more data is available.",
41634178
"operationId": "GetApplicationBoxes",
41644179
"parameters": [
41654180
{
@@ -4183,6 +4198,59 @@
41834198
"x-go-type": "uint64"
41844199
},
41854200
"x-go-type": "uint64"
4201+
},
4202+
{
4203+
"description": "Maximum number of boxes to return per page.",
4204+
"in": "query",
4205+
"name": "limit",
4206+
"schema": {
4207+
"type": "integer",
4208+
"x-go-type": "uint64"
4209+
},
4210+
"x-go-type": "uint64"
4211+
},
4212+
{
4213+
"description": "A box name, in the goal app call arg form 'encoding:value', representing the earliest box name to include in results. Use the next-token from a previous response.",
4214+
"in": "query",
4215+
"name": "next",
4216+
"schema": {
4217+
"type": "string"
4218+
}
4219+
},
4220+
{
4221+
"description": "A box name prefix, in the goal app call arg form 'encoding:value', to filter results by. Only boxes whose names start with this prefix will be returned.",
4222+
"in": "query",
4223+
"name": "prefix",
4224+
"schema": {
4225+
"type": "string"
4226+
}
4227+
},
4228+
{
4229+
"description": "Include additional items in the response. Use `values` to include box values. Multiple values can be comma-separated.",
4230+
"explode": false,
4231+
"in": "query",
4232+
"name": "include",
4233+
"schema": {
4234+
"items": {
4235+
"enum": [
4236+
"values"
4237+
],
4238+
"type": "string"
4239+
},
4240+
"type": "array"
4241+
},
4242+
"style": "form"
4243+
},
4244+
{
4245+
"description": "Return box data from the given round. The round must be within the node's available range.",
4246+
"in": "query",
4247+
"name": "round",
4248+
"schema": {
4249+
"format": "uint64",
4250+
"type": "integer",
4251+
"x-go-type": "basics.Round"
4252+
},
4253+
"x-go-type": "basics.Round"
41864254
}
41874255
],
41884256
"responses": {
@@ -4196,6 +4264,15 @@
41964264
"$ref": "#/components/schemas/BoxDescriptor"
41974265
},
41984266
"type": "array"
4267+
},
4268+
"next-token": {
4269+
"description": "Used for pagination, when making another request provide this token with the next parameter. The next token is the box name to use as the pagination cursor, encoded in the goal app call arg form.",
4270+
"type": "string"
4271+
},
4272+
"round": {
4273+
"description": "The round for which this information is relevant.",
4274+
"type": "integer",
4275+
"x-go-type": "basics.Round"
41994276
}
42004277
},
42014278
"required": [
@@ -4205,7 +4282,7 @@
42054282
}
42064283
}
42074284
},
4208-
"description": "Box names of an application"
4285+
"description": "Boxes of an application"
42094286
},
42104287
"400": {
42114288
"content": {

daemon/algod/api/client/restClient.go

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -475,12 +475,33 @@ func (client RestClient) ApplicationInformation(index basics.AppIndex) (response
475475
}
476476

477477
type applicationBoxesParams struct {
478-
Max uint64 `url:"max,omitempty"`
478+
Max uint64 `url:"max,omitempty"`
479+
Limit uint64 `url:"limit,omitempty"`
480+
Next string `url:"next,omitempty"`
481+
Prefix string `url:"prefix,omitempty"`
482+
Include string `url:"include,omitempty"`
483+
Round basics.Round `url:"round,omitempty"`
479484
}
480485

481486
// ApplicationBoxes gets the BoxesResponse associated with the passed application ID
482487
func (client RestClient) ApplicationBoxes(appID basics.AppIndex, maxBoxNum uint64) (response model.BoxesResponse, err error) {
483-
err = client.get(&response, fmt.Sprintf("/v2/applications/%d/boxes", appID), applicationBoxesParams{maxBoxNum})
488+
err = client.get(&response, fmt.Sprintf("/v2/applications/%d/boxes", appID), applicationBoxesParams{Max: maxBoxNum})
489+
return
490+
}
491+
492+
// ApplicationBoxesPage gets a page of boxes for the given application ID with pagination.
493+
func (client RestClient) ApplicationBoxesPage(appID basics.AppIndex, limit uint64, next string, prefix string, values bool, round basics.Round) (response model.BoxesResponse, err error) {
494+
var include string
495+
if values {
496+
include = "values"
497+
}
498+
err = client.get(&response, fmt.Sprintf("/v2/applications/%d/boxes", appID), applicationBoxesParams{
499+
Limit: limit,
500+
Next: next,
501+
Prefix: prefix,
502+
Include: include,
503+
Round: round,
504+
})
484505
return
485506
}
486507

daemon/algod/api/server/v2/errors.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,5 +48,6 @@ var (
4848
errOperationNotAvailableDuringCatchup = "operation not available during catchup"
4949
errRESTPayloadZeroLength = "payload was of zero length"
5050
errRoundGreaterThanTheLatest = "given round is greater than the latest round"
51+
errRoundTooOld = "given round is no longer available; use a more recent round"
5152
errFailedRetrievingTracer = "failed retrieving the expected tracer from ledger"
5253
)

0 commit comments

Comments
 (0)