Skip to content

Commit 1f122c7

Browse files
authored
Implement paginated API calls (#168)
1 parent 47fca57 commit 1f122c7

4 files changed

Lines changed: 494 additions & 31 deletions

File tree

src/main/java/io/github/jpmorganchase/fusion/Fusion.java

Lines changed: 105 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
import static io.github.jpmorganchase.fusion.filter.DatasetFilter.filterDatasets;
44

5+
import com.google.gson.Gson;
6+
import com.google.gson.JsonArray;
7+
import com.google.gson.JsonObject;
8+
import com.google.gson.JsonParser;
59
import io.github.jpmorganchase.fusion.api.APIManager;
610
import io.github.jpmorganchase.fusion.api.FusionAPIManager;
711
import io.github.jpmorganchase.fusion.api.exception.APICallException;
@@ -11,6 +15,7 @@
1115
import io.github.jpmorganchase.fusion.builders.APIConfiguredBuilders;
1216
import io.github.jpmorganchase.fusion.builders.Builders;
1317
import io.github.jpmorganchase.fusion.http.Client;
18+
import io.github.jpmorganchase.fusion.http.HttpResponse;
1419
import io.github.jpmorganchase.fusion.http.JdkClient;
1520
import io.github.jpmorganchase.fusion.model.*;
1621
import io.github.jpmorganchase.fusion.oauth.credential.BearerTokenCredentials;
@@ -36,14 +41,20 @@
3641
import java.util.Map;
3742
import java.util.Objects;
3843
import lombok.Builder;
44+
import lombok.extern.slf4j.Slf4j;
3945

4046
/**
4147
* Class representing the Fusion API, providing methods that correspond to available API endpoints
4248
*/
49+
@Slf4j
50+
@SuppressWarnings({"LombokSetterMayBeUsed", "LombokGetterMayBeUsed"})
4351
public class Fusion {
4452

4553
private static final DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd");
4654

55+
@SuppressWarnings({"FieldCanBeLocal", "FieldMayBeFinal"})
56+
private static int defaultPageSize = -1;
57+
4758
private final APIManager api;
4859
private String defaultCatalog;
4960
private final String defaultPath;
@@ -195,6 +206,91 @@ private Map<String, Map<String, Object>> callForMap(String url) {
195206
return responseParser.parseResourcesUntyped(json);
196207
}
197208

209+
/**
210+
* Makes paginated API calls and aggregates all results transparently.
211+
* This method handles the pagination logic internally, making multiple API calls
212+
* as needed and combining the results into a single response string.
213+
*
214+
* @param url the API endpoint URL
215+
* @return aggregated JSON response containing all pages of data
216+
*/
217+
private String callAPIWithPagination(String url) {
218+
log.debug("Starting paginated request to URL: {}", url);
219+
220+
Map<String, String> headers = new HashMap<>();
221+
headers.put("x-jpmc-paginate", "true");
222+
if (defaultPageSize > 0) {
223+
log.debug("Using page size: {}", defaultPageSize);
224+
headers.put("x-jpmc-page-size", String.valueOf(defaultPageSize));
225+
}
226+
227+
Gson gson = new Gson();
228+
JsonArray aggregatedResources = new JsonArray();
229+
String nextToken = null;
230+
int pageCount = 0;
231+
232+
do {
233+
pageCount++;
234+
if (nextToken != null) {
235+
headers.put("x-jpmc-next-token", nextToken);
236+
log.debug("Fetching page {} with next token", pageCount);
237+
} else {
238+
log.debug("Fetching page {}", pageCount);
239+
}
240+
241+
HttpResponse<String> response = this.api.callAPIWithResponse(url, headers);
242+
String pageJson = response.getBody();
243+
244+
JsonObject pageObject = JsonParser.parseString(pageJson).getAsJsonObject();
245+
if (pageObject.has("resources") && pageObject.get("resources").isJsonArray()) {
246+
JsonArray pageResources = pageObject.getAsJsonArray("resources");
247+
int pageResourceCount = pageResources.size();
248+
pageResources.forEach(aggregatedResources::add);
249+
log.debug("Retrieved {} resources from page {}", pageResourceCount, pageCount);
250+
}
251+
252+
nextToken = getHeaderValue(response.getHeaders(), "x-jpmc-next-token");
253+
254+
if (nextToken != null && !nextToken.isEmpty()) {
255+
log.debug("Next token received, more pages available");
256+
}
257+
258+
} while (nextToken != null && !nextToken.isEmpty());
259+
260+
log.debug(
261+
"Pagination complete. Total pages fetched: {}, Total resources: {}",
262+
pageCount,
263+
aggregatedResources.size());
264+
265+
JsonObject result = new JsonObject();
266+
result.add("resources", aggregatedResources);
267+
return gson.toJson(result);
268+
}
269+
270+
/**
271+
* Gets a header value from the response headers map (case-insensitive).
272+
*
273+
* @param headers the response headers map
274+
* @param headerName the header name to look for
275+
* @return the header value, or null if not found
276+
*/
277+
@SuppressWarnings("SameParameterValue")
278+
private String getHeaderValue(Map<String, List<String>> headers, String headerName) {
279+
if (headers == null || headerName == null) {
280+
return null;
281+
}
282+
283+
for (Map.Entry<String, List<String>> entry : headers.entrySet()) {
284+
if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(headerName)) {
285+
List<String> values = entry.getValue();
286+
if (values != null && !values.isEmpty()) {
287+
return values.get(0);
288+
}
289+
}
290+
}
291+
return null;
292+
}
293+
198294
/**
199295
* Get a list of the catalogs available to the API account.
200296
*
@@ -203,7 +299,8 @@ private Map<String, Map<String, Object>> callForMap(String url) {
203299
* @throws OAuthException if a token could not be retrieved for authentication
204300
*/
205301
public Map<String, Catalog> listCatalogs() {
206-
String json = this.api.callAPI(rootURL.concat("catalogs"));
302+
String url = rootURL.concat("catalogs");
303+
String json = callAPIWithPagination(url);
207304
return responseParser.parseCatalogResponse(json);
208305
}
209306

@@ -223,7 +320,7 @@ public Map<String, Map<String, Object>> catalogResources(String catalogName) {
223320
/**
224321
* Get a filtered list of the data products in the specified catalog
225322
* <p>
226-
* Note that as of current version this search capability is not yet implemented
323+
* Note that as of the current version, this search capability is not yet implemented
227324
*
228325
* @param catalogName identifier of the catalog to be queried
229326
* @param contains a search keyword.
@@ -235,7 +332,7 @@ public Map<String, Map<String, Object>> catalogResources(String catalogName) {
235332
public Map<String, DataProduct> listProducts(String catalogName, String contains, boolean idContains) {
236333
// TODO: unimplemented logic implied by the method parameters
237334
String url = String.format("%1scatalogs/%2s/products", this.rootURL, catalogName);
238-
String json = this.api.callAPI(url);
335+
String json = callAPIWithPagination(url);
239336
return responseParser.parseDataProductResponse(json);
240337
}
241338

@@ -266,7 +363,7 @@ public Map<String, DataProduct> listProducts() {
266363
/**
267364
* Get a filtered list of the datasets in the specified catalog
268365
* <p>
269-
* Note that as of current version this search capability is not yet implemented
366+
* Note that as of the current version, this search capability is not yet implemented
270367
*
271368
* @param catalogName identifier of the catalog to be queried
272369
* @param contains a search keyword.
@@ -277,7 +374,7 @@ public Map<String, DataProduct> listProducts() {
277374
*/
278375
public Map<String, Dataset> listDatasets(String catalogName, String contains, boolean idContains) {
279376
String url = String.format("%1scatalogs/%2s/datasets", this.rootURL, catalogName);
280-
String json = this.api.callAPI(url);
377+
String json = callAPIWithPagination(url);
281378
return filterDatasets(responseParser.parseDatasetResponse(json, catalogName), contains, idContains);
282379
}
283380

@@ -400,7 +497,7 @@ public Map<String, Map<String, Object>> datasetResources(String dataset) {
400497
*/
401498
public Map<String, DatasetSeries> listDatasetMembers(String catalogName, String dataset) {
402499
String url = String.format("%1scatalogs/%2s/datasets/%3s/datasetseries", this.rootURL, catalogName, dataset);
403-
String json = this.api.callAPI(url);
500+
String json = callAPIWithPagination(url);
404501
return responseParser.parseDatasetSeriesResponse(json);
405502
}
406503

@@ -460,7 +557,7 @@ public Map<String, Map<String, Object>> datasetMemberResources(String dataset, S
460557
*/
461558
public Map<String, Attribute> listAttributes(String catalogName, String dataset) {
462559
String url = String.format("%1scatalogs/%2s/datasets/%3s/attributes", this.rootURL, catalogName, dataset);
463-
String json = this.api.callAPI(url);
560+
String json = callAPIWithPagination(url);
464561
return responseParser.parseAttributeResponse(json, catalogName, dataset);
465562
}
466563

@@ -502,11 +599,10 @@ public Map<String, Map<String, Object>> attributeResources(String catalogName, S
502599
* @throws OAuthException if a token could not be retrieved for authentication
503600
*/
504601
public Map<String, Distribution> listDistributions(String catalogName, String dataset, String seriesMember) {
505-
506602
String url = String.format(
507603
"%1scatalogs/%2s/datasets/%3s/datasetseries/%4s/distributions",
508604
this.rootURL, catalogName, dataset, seriesMember);
509-
String json = this.api.callAPI(url);
605+
String json = callAPIWithPagination(url);
510606
return responseParser.parseDistributionResponse(json);
511607
}
512608

src/main/java/io/github/jpmorganchase/fusion/api/APIManager.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
import io.github.jpmorganchase.fusion.api.exception.APICallException;
44
import io.github.jpmorganchase.fusion.api.operations.APIDownloadOperations;
55
import io.github.jpmorganchase.fusion.api.operations.APIUploadOperations;
6+
import io.github.jpmorganchase.fusion.http.HttpResponse;
67
import java.io.UnsupportedEncodingException;
78
import java.net.MalformedURLException;
89
import java.net.URL;
910
import java.net.URLEncoder;
11+
import java.util.Map;
1012

1113
public interface APIManager extends APIDownloadOperations, APIUploadOperations {
1214

@@ -19,6 +21,16 @@ public interface APIManager extends APIDownloadOperations, APIUploadOperations {
1921
*/
2022
String callAPI(String apiPath) throws APICallException;
2123

24+
/**
25+
* Sends a GET request to the specified API endpoint with custom headers and returns the full HTTP response.
26+
*
27+
* @param apiPath the API endpoint path to which the GET request will be sent
28+
* @param headers additional HTTP headers to include in the request
29+
* @return the full {@code HttpResponse} including headers and body
30+
* @throws APICallException if the response status indicates an error or the request fails
31+
*/
32+
HttpResponse<String> callAPIWithResponse(String apiPath, Map<String, String> headers) throws APICallException;
33+
2234
String callAPIToPost(String apiPath) throws APICallException;
2335

2436
/**

src/main/java/io/github/jpmorganchase/fusion/api/FusionAPIManager.java

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,38 @@ public String callAPI(String apiPath) throws APICallException {
6363
return response.getBody();
6464
}
6565

66+
/**
67+
* Sends a GET request to the specified API endpoint with custom headers and returns the full HTTP response.
68+
*
69+
* <p>This method constructs the necessary authorization headers using a bearer token from
70+
* the {@code tokenProvider}, merges them with the provided custom headers, and sends a GET
71+
* request to the specified {@code apiPath} using the {@code httpClient}. It checks the HTTP
72+
* response status for errors and returns the full response including headers.
73+
*
74+
* @param apiPath the API endpoint path to which the GET request will be sent
75+
* @param customHeaders additional HTTP headers to include in the request
76+
* @return the full {@code HttpResponse} including status, headers, and body
77+
* @throws APICallException if the response status indicates an error or the request fails
78+
*/
79+
@Override
80+
public HttpResponse<String> callAPIWithResponse(String apiPath, Map<String, String> customHeaders)
81+
throws APICallException {
82+
Map<String, String> requestHeaders = new HashMap<>();
83+
requestHeaders.put("Authorization", "Bearer " + tokenProvider.getSessionBearerToken());
84+
85+
if (customHeaders != null) {
86+
customHeaders.forEach((key, value) -> {
87+
if (!"Authorization".equalsIgnoreCase(key)) {
88+
requestHeaders.put(key, value);
89+
}
90+
});
91+
}
92+
93+
HttpResponse<String> response = httpClient.get(APIManager.encodeUrl(apiPath), requestHeaders);
94+
checkResponseStatus(response);
95+
return response;
96+
}
97+
6698
@Override
6799
public String callAPIToPost(String apiPath) throws APICallException {
68100
Map<String, String> requestHeaders = new HashMap<>();

0 commit comments

Comments
 (0)