Skip to content

Commit 86476e6

Browse files
committed
TUN-8281: Run cloudflared query list tunnels/routes endpoint in a paginated way
Before this commit the commands that listed tunnels and tunnel routes would be limited to 1000 results by the server. Now, the commands will call the endpoints until the result set is exhausted. This can take a long time if there are thousands of pages available, since each request is executed synchronously. From a user's perspective, nothing changes.
1 parent da6fac4 commit 86476e6

File tree

6 files changed

+107
-90
lines changed

6 files changed

+107
-90
lines changed

cfapi/base_client.go

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -109,20 +109,34 @@ func (r *RESTClient) sendRequest(method string, url url.URL, body interface{}) (
109109
return r.client.Do(req)
110110
}
111111

112-
func parseResponse(reader io.Reader, data interface{}) error {
112+
func parseResponseEnvelope(reader io.Reader) (*response, error) {
113113
// Schema for Tunnelstore responses in the v1 API.
114114
// Roughly, it's a wrapper around a particular result that adds failures/errors/etc
115115
var result response
116116
// First, parse the wrapper and check the API call succeeded
117117
if err := json.NewDecoder(reader).Decode(&result); err != nil {
118-
return errors.Wrap(err, "failed to decode response")
118+
return nil, errors.Wrap(err, "failed to decode response")
119119
}
120120
if err := result.checkErrors(); err != nil {
121-
return err
121+
return nil, err
122122
}
123123
if !result.Success {
124-
return ErrAPINoSuccess
124+
return nil, ErrAPINoSuccess
125125
}
126+
127+
return &result, nil
128+
}
129+
130+
func parseResponse(reader io.Reader, data interface{}) error {
131+
result, err := parseResponseEnvelope(reader)
132+
if err != nil {
133+
return err
134+
}
135+
136+
return parseResponseBody(result, data)
137+
}
138+
139+
func parseResponseBody(result *response, data interface{}) error {
126140
// At this point we know the API call succeeded, so, parse out the inner
127141
// result into the datatype provided as a parameter.
128142
if err := json.Unmarshal(result.Result, &data); err != nil {
@@ -131,11 +145,58 @@ func parseResponse(reader io.Reader, data interface{}) error {
131145
return nil
132146
}
133147

148+
func fetchExhaustively[T any](requestFn func(int) (*http.Response, error)) ([]*T, error) {
149+
page := 0
150+
var fullResponse []*T
151+
152+
for {
153+
page += 1
154+
envelope, parsedBody, err := fetchPage[T](requestFn, page)
155+
156+
if err != nil {
157+
return nil, errors.Wrap(err, fmt.Sprintf("Error Parsing page %d", page))
158+
}
159+
160+
fullResponse = append(fullResponse, parsedBody...)
161+
if envelope.Pagination.Count < envelope.Pagination.PerPage || len(fullResponse) >= envelope.Pagination.TotalCount {
162+
break
163+
}
164+
165+
}
166+
return fullResponse, nil
167+
}
168+
169+
func fetchPage[T any](requestFn func(int) (*http.Response, error), page int) (*response, []*T, error) {
170+
pageResp, err := requestFn(page)
171+
if err != nil {
172+
return nil, nil, errors.Wrap(err, "REST request failed")
173+
}
174+
defer pageResp.Body.Close()
175+
if pageResp.StatusCode == http.StatusOK {
176+
envelope, err := parseResponseEnvelope(pageResp.Body)
177+
if err != nil {
178+
return nil, nil, err
179+
}
180+
var parsedRspBody []*T
181+
return envelope, parsedRspBody, parseResponseBody(envelope, &parsedRspBody)
182+
183+
}
184+
return nil, nil, errors.New(fmt.Sprintf("Failed to fetch page. Server returned: %d", pageResp.StatusCode))
185+
}
186+
134187
type response struct {
135-
Success bool `json:"success,omitempty"`
136-
Errors []apiErr `json:"errors,omitempty"`
137-
Messages []string `json:"messages,omitempty"`
138-
Result json.RawMessage `json:"result,omitempty"`
188+
Success bool `json:"success,omitempty"`
189+
Errors []apiErr `json:"errors,omitempty"`
190+
Messages []string `json:"messages,omitempty"`
191+
Result json.RawMessage `json:"result,omitempty"`
192+
Pagination Pagination `json:"result_info,omitempty"`
193+
}
194+
195+
type Pagination struct {
196+
Count int `json:"count,omitempty"`
197+
Page int `json:"page,omitempty"`
198+
PerPage int `json:"per_page,omitempty"`
199+
TotalCount int `json:"total_count,omitempty"`
139200
}
140201

141202
func (r *response) checkErrors() error {

cfapi/ip_route.go

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -137,20 +137,24 @@ type GetRouteByIpParams struct {
137137
}
138138

139139
// ListRoutes calls the Tunnelstore GET endpoint for all routes under an account.
140+
// Due to pagination on the server side it will call the endpoint multiple times if needed.
140141
func (r *RESTClient) ListRoutes(filter *IpRouteFilter) ([]*DetailedRoute, error) {
141-
endpoint := r.baseEndpoints.accountRoutes
142-
endpoint.RawQuery = filter.Encode()
143-
resp, err := r.sendRequest("GET", endpoint, nil)
144-
if err != nil {
145-
return nil, errors.Wrap(err, "REST request failed")
146-
}
147-
defer resp.Body.Close()
148-
149-
if resp.StatusCode == http.StatusOK {
150-
return parseListDetailedRoutes(resp.Body)
142+
fetchFn := func(page int) (*http.Response, error) {
143+
endpoint := r.baseEndpoints.accountRoutes
144+
filter.Page(page)
145+
endpoint.RawQuery = filter.Encode()
146+
rsp, err := r.sendRequest("GET", endpoint, nil)
147+
148+
if err != nil {
149+
return nil, errors.Wrap(err, "REST request failed")
150+
}
151+
if rsp.StatusCode != http.StatusOK {
152+
rsp.Body.Close()
153+
return nil, r.statusCodeToError("list routes", rsp)
154+
}
155+
return rsp, nil
151156
}
152-
153-
return nil, r.statusCodeToError("list routes", resp)
157+
return fetchExhaustively[DetailedRoute](fetchFn)
154158
}
155159

156160
// AddRoute calls the Tunnelstore POST endpoint for a given route.
@@ -208,12 +212,6 @@ func (r *RESTClient) GetByIP(params GetRouteByIpParams) (DetailedRoute, error) {
208212
return DetailedRoute{}, r.statusCodeToError("get route by IP", resp)
209213
}
210214

211-
func parseListDetailedRoutes(body io.ReadCloser) ([]*DetailedRoute, error) {
212-
var routes []*DetailedRoute
213-
err := parseResponse(body, &routes)
214-
return routes, err
215-
}
216-
217215
func parseRoute(body io.ReadCloser) (Route, error) {
218216
var route Route
219217
err := parseResponse(body, &route)

cfapi/ip_route_filter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@ func (f *IpRouteFilter) MaxFetchSize(max uint) {
167167
f.queryParams.Set("per_page", strconv.Itoa(int(max)))
168168
}
169169

170+
func (f *IpRouteFilter) Page(page int) {
171+
f.queryParams.Set("page", strconv.Itoa(page))
172+
}
173+
170174
func (f IpRouteFilter) Encode() string {
171175
return f.queryParams.Encode()
172176
}

cfapi/tunnel.go

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -177,25 +177,22 @@ func (r *RESTClient) DeleteTunnel(tunnelID uuid.UUID, cascade bool) error {
177177
}
178178

179179
func (r *RESTClient) ListTunnels(filter *TunnelFilter) ([]*Tunnel, error) {
180-
endpoint := r.baseEndpoints.accountLevel
181-
endpoint.RawQuery = filter.encode()
182-
resp, err := r.sendRequest("GET", endpoint, nil)
183-
if err != nil {
184-
return nil, errors.Wrap(err, "REST request failed")
185-
}
186-
defer resp.Body.Close()
187-
188-
if resp.StatusCode == http.StatusOK {
189-
return parseListTunnels(resp.Body)
180+
fetchFn := func(page int) (*http.Response, error) {
181+
endpoint := r.baseEndpoints.accountLevel
182+
filter.Page(page)
183+
endpoint.RawQuery = filter.encode()
184+
rsp, err := r.sendRequest("GET", endpoint, nil)
185+
if err != nil {
186+
return nil, errors.Wrap(err, "REST request failed")
187+
}
188+
if rsp.StatusCode != http.StatusOK {
189+
rsp.Body.Close()
190+
return nil, r.statusCodeToError("list tunnels", rsp)
191+
}
192+
return rsp, nil
190193
}
191194

192-
return nil, r.statusCodeToError("list tunnels", resp)
193-
}
194-
195-
func parseListTunnels(body io.ReadCloser) ([]*Tunnel, error) {
196-
var tunnels []*Tunnel
197-
err := parseResponse(body, &tunnels)
198-
return tunnels, err
195+
return fetchExhaustively[Tunnel](fetchFn)
199196
}
200197

201198
func (r *RESTClient) ListActiveClients(tunnelID uuid.UUID) ([]*ActiveClient, error) {

cfapi/tunnel_filter.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@ func (f *TunnelFilter) MaxFetchSize(max uint) {
5050
f.queryParams.Set("per_page", strconv.Itoa(int(max)))
5151
}
5252

53+
func (f *TunnelFilter) Page(page int) {
54+
f.queryParams.Set("page", strconv.Itoa(page))
55+
}
56+
5357
func (f TunnelFilter) encode() string {
5458
return f.queryParams.Encode()
5559
}

cfapi/tunnel_test.go

Lines changed: 0 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ package cfapi
33
import (
44
"bytes"
55
"fmt"
6-
"io"
76
"net"
87
"reflect"
98
"strings"
@@ -16,52 +15,6 @@ import (
1615

1716
var loc, _ = time.LoadLocation("UTC")
1817

19-
func Test_parseListTunnels(t *testing.T) {
20-
type args struct {
21-
body string
22-
}
23-
tests := []struct {
24-
name string
25-
args args
26-
want []*Tunnel
27-
wantErr bool
28-
}{
29-
{
30-
name: "empty list",
31-
args: args{body: `{"success": true, "result": []}`},
32-
want: []*Tunnel{},
33-
},
34-
{
35-
name: "success is false",
36-
args: args{body: `{"success": false, "result": []}`},
37-
wantErr: true,
38-
},
39-
{
40-
name: "errors are present",
41-
args: args{body: `{"errors": [{"code": 1003, "message":"An A, AAAA or CNAME record already exists with that host"}], "result": []}`},
42-
wantErr: true,
43-
},
44-
{
45-
name: "invalid response",
46-
args: args{body: `abc`},
47-
wantErr: true,
48-
},
49-
}
50-
for _, tt := range tests {
51-
t.Run(tt.name, func(t *testing.T) {
52-
body := io.NopCloser(bytes.NewReader([]byte(tt.args.body)))
53-
got, err := parseListTunnels(body)
54-
if (err != nil) != tt.wantErr {
55-
t.Errorf("parseListTunnels() error = %v, wantErr %v", err, tt.wantErr)
56-
return
57-
}
58-
if !reflect.DeepEqual(got, tt.want) {
59-
t.Errorf("parseListTunnels() = %v, want %v", got, tt.want)
60-
}
61-
})
62-
}
63-
}
64-
6518
func Test_unmarshalTunnel(t *testing.T) {
6619
type args struct {
6720
body string

0 commit comments

Comments
 (0)