Skip to content

Commit 3c4a774

Browse files
committed
⚡️ Add helper class to deal with paginated endpoints
Downstream issue: jenkinsci/hetzner-cloud-plugin#104 Signed-off-by: Richard Kosegi <[email protected]>
1 parent 9d9049b commit 3c4a774

File tree

5 files changed

+299
-1
lines changed

5 files changed

+299
-1
lines changed

src/main/java/cloud/dnation/hetznerclient/HetznerApi.java

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,20 @@ Call<GetServersBySelectorResponse> getServersBySelector(@Query("label_selector")
172172
@GET("/v1/networks")
173173
Call<GetNetworksBySelectorResponse> getNetworkBySelector(@Query("label_selector") String selector);
174174

175+
/**
176+
* Get all networks matching given label selector.
177+
*
178+
* @param selector label selector used to match networks
179+
* @param page page index
180+
* @param perPage number of items per page. API imposes limit on top of this value.
181+
* @return list of networks
182+
* see <a href="https://docs.hetzner.cloud/#networks-get-all-networks">API reference</a>
183+
*/
184+
@GET("/v1/networks")
185+
Call<GetNetworksBySelectorResponse> getNetworksBySelector(@Query("label_selector") String selector,
186+
@Query("page") int page,
187+
@Query("per_page") int perPage);
188+
175189
/**
176190
* Get network detail based on provided network ID.
177191
*
@@ -223,6 +237,21 @@ Call<GetServersBySelectorResponse> getServersBySelector(@Query("label_selector")
223237
@GET("/v1/placement_groups/{id}")
224238
Call<GetPlacementGroupByIdResponse> getPlacementGroupById(@Path("id") long id);
225239

240+
/**
241+
* Get all Primary IP objects matching given selector.
242+
*
243+
* @param selector Can be used to filter resources by labels.
244+
* The response will only contain resources matching the label selector.
245+
* @param page page index
246+
* @param perPage number of items per page. API imposes limit on top of this value.
247+
* @return returns all Primary IP objects.
248+
* see <a href="https://docs.hetzner.cloud/#primary-ips-get-all-primary-ips">API reference</a>
249+
*/
250+
@GET("/v1/primary_ips")
251+
Call<GetAllPrimaryIpsResponse> getPrimaryIpsBySelector(@Query("label_selector") String selector,
252+
@Query("page") int page,
253+
@Query("per_page") int perPage);
254+
226255
/**
227256
* Get all Primary IP objects.
228257
*
@@ -234,7 +263,6 @@ Call<GetServersBySelectorResponse> getServersBySelector(@Query("label_selector")
234263
@GET("/v1/primary_ips")
235264
Call<GetAllPrimaryIpsResponse> getAllPrimaryIps(@Query("label_selector") String selector);
236265

237-
238266
/**
239267
* Get volume detail based on ID.
240268
*
@@ -255,4 +283,19 @@ Call<GetServersBySelectorResponse> getServersBySelector(@Query("label_selector")
255283
*/
256284
@GET("/v1/volumes")
257285
Call<GetVolumesResponse> getVolumes(@Query("label_selector") String selector);
286+
287+
/**
288+
* Get all volumes.
289+
*
290+
* @param selector Can be used to filter resources by labels.
291+
* The response will only contain resources matching the label selector.
292+
* @param page page index
293+
* @param perPage number of items per page. API imposes limit on top of this value.
294+
* @return list of volumes
295+
* see <a href="https://docs.hetzner.cloud/#volumes-get-all-volumes">API reference</a>
296+
*/
297+
@GET("/v1/volumes")
298+
Call<GetVolumesResponse> getVolumes(@Query("label_selector") String selector,
299+
@Query("page") int page,
300+
@Query("per_page") int perPage);
258301
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/*
2+
* Copyright 2025 https://dnation.cloud
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package cloud.dnation.hetznerclient;
17+
18+
import com.google.common.base.Preconditions;
19+
import lombok.experimental.UtilityClass;
20+
import retrofit2.Call;
21+
import retrofit2.Response;
22+
23+
import java.io.IOException;
24+
import java.util.ArrayList;
25+
import java.util.List;
26+
import java.util.Objects;
27+
import java.util.function.BiFunction;
28+
import java.util.function.Function;
29+
30+
@UtilityClass
31+
public class PagedResourceHelper {
32+
/**
33+
* Consume all items from paginated REST endpoint.
34+
*
35+
* @param labelSelector label selector to restrict items only to those that match selector
36+
* @param pageSupplier {@link BiFunction} that takes page index (zero based) and selector and produces {@link Response}
37+
* @param itemsGetter {@link Function} that takes response from pageSupplier and extracts list of items
38+
* @return all items combined to single list
39+
* @param <T> item type
40+
* @param <X> REST endpoint response type
41+
*/
42+
public static <T extends IdentifiableResource, X extends AbstractSearchResponse> List<T> fetchItems(
43+
String labelSelector,
44+
BiFunction<Integer, String, Call<X>> pageSupplier, Function<X, List<T>> itemsGetter) throws IOException {
45+
final List<T> result = new ArrayList<>();
46+
for (int i = 0; ; i++) {
47+
final Response<X> page = pageSupplier.apply(i, labelSelector).execute();
48+
assertValidResponse(page);
49+
final X body = page.body();
50+
Objects.requireNonNull(body, "Missing response body");
51+
final Meta meta = body.getMeta();
52+
result.addAll(itemsGetter.apply(body));
53+
Objects.requireNonNull(meta, "Missing meta response object");
54+
Objects.requireNonNull(meta.getPagination(), "Missing pagination inside meta response object");
55+
if (meta.getPagination().getNextPage() == null) {
56+
// last page
57+
break;
58+
}
59+
}
60+
return result;
61+
}
62+
63+
public static List<PrimaryIpDetail> getAllPrimaryIps(HetznerApi api, String labelSelector) throws IOException {
64+
return fetchItems(labelSelector, (pageId, sel) ->
65+
api.getPrimaryIpsBySelector(sel, pageId, 25), GetAllPrimaryIpsResponse::getPrimaryIps);
66+
}
67+
68+
public static List<ServerDetail> getAllServers(HetznerApi api, String labelSelector) throws IOException {
69+
return fetchItems(labelSelector, (pageId, sel) ->
70+
api.getServersBySelector(sel, pageId, 25), GetServersBySelectorResponse::getServers);
71+
}
72+
73+
private static <X extends AbstractSearchResponse> void assertValidResponse(Response<X> response) {
74+
Preconditions.checkArgument(response.isSuccessful(),
75+
"Invalid response code: %d", response.code());
76+
}
77+
}

src/test/java/cloud/dnation/hetznerclient/BasicTest.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,10 @@
2424
import retrofit2.Response;
2525

2626
import java.io.IOException;
27+
import java.util.ArrayList;
28+
import java.util.List;
29+
import java.util.function.BiFunction;
30+
import java.util.function.Function;
2731

2832
import static cloud.dnation.hetznerclient.TestHelper.resourceAsString;
2933
import static org.junit.Assert.assertEquals;
@@ -136,4 +140,15 @@ public void testGetFirewallByIdInvalid() throws IOException {
136140
Response<GetNetworkByIdResponse> response = call.execute();
137141
assertEquals(404, response.code());
138142
}
143+
144+
@Test
145+
public void testPaginationHelper() throws IOException {
146+
ws.enqueue(new MockResponse().setBody(resourceAsString("paging-primary-ips-1.json")));
147+
ws.enqueue(new MockResponse().setBody(resourceAsString("paging-primary-ips-2.json")));
148+
List<PrimaryIpDetail> items = PagedResourceHelper.getAllPrimaryIps(api, "");
149+
assertEquals(27, items.size());
150+
assertEquals("1.2.3.4", items.get(0).getIp());
151+
assertEquals("1.2.3.29", items.get(25).getIp());
152+
assertEquals("1.2.3.30", items.get(26).getIp());
153+
}
139154
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
{
2+
"primary_ips": [
3+
{
4+
"id": 7890123,
5+
"name": "primary_ip-123456",
6+
"ip": "1.2.3.4"
7+
},
8+
{
9+
"id": 7890123,
10+
"name": "primary_ip-123456",
11+
"ip": "1.2.3.5"
12+
},
13+
{
14+
"id": 7890123,
15+
"name": "primary_ip-123456",
16+
"ip": "1.2.3.6"
17+
},
18+
{
19+
"id": 7890123,
20+
"name": "primary_ip-123456",
21+
"ip": "1.2.3.7"
22+
},
23+
{
24+
"id": 7890123,
25+
"name": "primary_ip-123456",
26+
"ip": "1.2.3.8"
27+
},
28+
{
29+
"id": 7890123,
30+
"name": "primary_ip-123456",
31+
"ip": "1.2.3.9"
32+
},
33+
{
34+
"id": 7890123,
35+
"name": "primary_ip-123456",
36+
"ip": "1.2.3.10"
37+
},
38+
{
39+
"id": 7890123,
40+
"name": "primary_ip-123456",
41+
"ip": "1.2.3.11"
42+
},
43+
{
44+
"id": 7890123,
45+
"name": "primary_ip-123456",
46+
"ip": "1.2.3.12"
47+
},
48+
{
49+
"id": 7890123,
50+
"name": "primary_ip-123456",
51+
"ip": "1.2.3.13"
52+
},
53+
{
54+
"id": 7890123,
55+
"name": "primary_ip-123456",
56+
"ip": "1.2.3.14"
57+
},
58+
{
59+
"id": 7890123,
60+
"name": "primary_ip-123456",
61+
"ip": "1.2.3.15"
62+
},
63+
{
64+
"id": 7890123,
65+
"name": "primary_ip-123456",
66+
"ip": "1.2.3.16"
67+
},
68+
{
69+
"id": 7890123,
70+
"name": "primary_ip-123456",
71+
"ip": "1.2.3.17"
72+
},
73+
{
74+
"id": 7890123,
75+
"name": "primary_ip-123456",
76+
"ip": "1.2.3.18"
77+
},
78+
{
79+
"id": 7890123,
80+
"name": "primary_ip-123456",
81+
"ip": "1.2.3.19"
82+
},
83+
{
84+
"id": 7890123,
85+
"name": "primary_ip-123456",
86+
"ip": "1.2.3.20"
87+
},
88+
{
89+
"id": 7890123,
90+
"name": "primary_ip-123456",
91+
"ip": "1.2.3.21"
92+
},
93+
{
94+
"id": 7890123,
95+
"name": "primary_ip-123456",
96+
"ip": "1.2.3.22"
97+
},
98+
{
99+
"id": 7890123,
100+
"name": "primary_ip-123456",
101+
"ip": "1.2.3.23"
102+
},
103+
{
104+
"id": 7890123,
105+
"name": "primary_ip-123456",
106+
"ip": "1.2.3.24"
107+
},
108+
{
109+
"id": 7890123,
110+
"name": "primary_ip-123456",
111+
"ip": "1.2.3.25"
112+
},
113+
{
114+
"id": 7890123,
115+
"name": "primary_ip-123456",
116+
"ip": "1.2.3.26"
117+
},
118+
{
119+
"id": 7890123,
120+
"name": "primary_ip-123456",
121+
"ip": "1.2.3.27"
122+
},
123+
{
124+
"id": 7890123,
125+
"name": "primary_ip-123456",
126+
"ip": "1.2.3.28"
127+
}
128+
],
129+
"meta": {
130+
"pagination": {
131+
"page": 1,
132+
"per_page": 25,
133+
"previous_page": null,
134+
"next_page": 1,
135+
"last_page": 2,
136+
"total_entries": 27
137+
}
138+
}
139+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"primary_ips": [
3+
{
4+
"id": 7890123,
5+
"name": "primary_ip-29",
6+
"ip": "1.2.3.29"
7+
},
8+
{
9+
"id": 7890123,
10+
"name": "primary_ip-30",
11+
"ip": "1.2.3.30"
12+
}
13+
],
14+
"meta": {
15+
"pagination": {
16+
"page": 1,
17+
"per_page": 25,
18+
"previous_page": 1,
19+
"next_page": null,
20+
"last_page": 2,
21+
"total_entries": 27
22+
}
23+
}
24+
}

0 commit comments

Comments
 (0)