Skip to content

Commit 1daa922

Browse files
Merge commit from fork
Fix memory leak and thread-safety issues in pagination
2 parents e46f8fb + 50778f6 commit 1daa922

File tree

9 files changed

+1038
-61
lines changed

9 files changed

+1038
-61
lines changed

‎api/src/main/java/com/okta/sdk/helper/PaginationUtil.java‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@
1515
*/
1616
packagecom.okta.sdk.helper;
1717

18-
importcom.okta.commons.lang.Assert;
19-
importcom.okta.sdk.resource.client.ApiClient;
20-
importorg.slf4j.Logger;
21-
importorg.slf4j.LoggerFactory;
22-
2318
importjava.io.UnsupportedEncodingException;
2419
importjava.net.MalformedURLException;
2520
importjava.net.URL;
@@ -29,6 +24,12 @@
2924
importjava.util.List;
3025
importjava.util.Map;
3126

27+
importorg.slf4j.Logger;
28+
importorg.slf4j.LoggerFactory;
29+
30+
importcom.okta.commons.lang.Assert;
31+
importcom.okta.sdk.resource.client.ApiClient;
32+
3233
/**
3334
* Helper class for Pagination related functions.
3435
*
@@ -45,7 +46,9 @@ public class PaginationUtil{
4546
*
4647
* @param apiClient{@link ApiClient} instance
4748
* @return the 'after' resource id
49+
* @deprecated Use{@link com.okta.sdk.resource.client.PagedIterable} instead for automatic pagination
4850
*/
51+
@Deprecated(forRemoval = true, since = "24.1.0")
4952
publicstaticStringgetAfter(ApiClientapiClient){
5053
returngetAfter(getNextPage(apiClient));
5154
}
@@ -79,6 +82,7 @@ private static String getAfter(String nextPage){
7982
* @param apiClient the{@link ApiClient} instance
8083
* @return the next page URL string
8184
*/
85+
@SuppressWarnings("removal")
8286
privatestaticStringgetNextPage(ApiClientapiClient){
8387

8488
Assert.notNull(apiClient, "apiClient cannot be null");
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
/*
2+
* Copyright (c) 2025-Present, Okta, Inc.
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+
packagecom.okta.sdk.resource.client;
17+
18+
importjava.util.List;
19+
importjava.util.Map;
20+
21+
/**
22+
* A stateless data object that represents a complete API response.
23+
*
24+
* This class packages together everything from an HTTP response:
25+
* - The response body (deserialized data)
26+
* - The response headers (including Link headers for pagination)
27+
* - The HTTP status code (200, 404, etc.)
28+
*
29+
* Unlike the old approach which stored headers in a shared HashMap,
30+
* ApiResponse is an immutable, thread-safe object that can be passed
31+
* around without side effects.
32+
*
33+
* Example usage:
34+
* <pre>
35+
* ApiResponse&lt;List&lt;User&gt;&gt; response = apiClient.invokeAPIWithHttpInfo(...);
36+
*
37+
* int status = response.getStatusCode(); // e.g., 200
38+
* List&lt;User&gt; users = response.getBody(); // The actual data
39+
* Map&lt;String, List&lt;String&gt;&gt; headers = response.getHeaders(); // Response headers
40+
*
41+
* // Check for pagination
42+
* List&lt;String&gt; linkHeaders = headers.get("Link");
43+
* </pre>
44+
*
45+
* @param <T> The type of the response body (e.g., List&lt;User&gt;, Group, etc.)
46+
* @since 17.0.0
47+
*/
48+
publicclassApiResponse<T>{
49+
50+
privatefinalintstatusCode;
51+
privatefinalMap<String, List<String>> headers;
52+
privatefinalTbody;
53+
54+
/**
55+
* Constructs a new ApiResponse with the given status code, headers, and body.
56+
*
57+
* @param statusCode the HTTP status code (e.g., 200, 404, 500)
58+
* @param headers the response headers as a map (header name → list of values)
59+
* @param body the deserialized response body (can be null for 204 No Content)
60+
*/
61+
publicApiResponse(intstatusCode, Map<String, List<String>> headers, Tbody){
62+
this.statusCode = statusCode;
63+
this.headers = headers;
64+
this.body = body;
65+
}
66+
67+
/**
68+
* Gets the HTTP status code from the response.
69+
*
70+
* @return the status code (e.g., 200 for success, 404 for not found)
71+
*/
72+
publicintgetStatusCode(){
73+
returnstatusCode;
74+
}
75+
76+
/**
77+
* Gets the response headers.
78+
*
79+
* Common headers include:
80+
* - "Link": Pagination links (e.g., rel="next")
81+
* - "Content-Type": Response content type
82+
* - "X-Rate-Limit-Remaining": API rate limit info
83+
*
84+
* @return a map of header names to lists of header values
85+
*/
86+
publicMap<String, List<String>> getHeaders(){
87+
returnheaders;
88+
}
89+
90+
/**
91+
* Gets the deserialized response body.
92+
*
93+
* This is the actual data returned by the API, already converted
94+
* from JSON into Java objects.
95+
*
96+
* @return the response body, or null if the response was empty (204 No Content)
97+
*/
98+
publicTgetBody(){
99+
returnbody;
100+
}
101+
102+
@Override
103+
publicStringtoString(){
104+
return"ApiResponse{" +
105+
"statusCode=" + statusCode +
106+
", headersCount=" + (headers != null ? headers.size() : 0) +
107+
", body=" + (body != null ? body.getClass().getSimpleName() : "null") +
108+
'}';
109+
}
110+
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
/*
2+
* Copyright (c) 2025-Present, Okta, Inc.
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+
packagecom.okta.sdk.resource.client;
17+
18+
importjava.util.Iterator;
19+
importjava.util.List;
20+
importjava.util.function.Function;
21+
22+
/**
23+
* A new lazy, paginated iterable.
24+
*
25+
* This class is a "recipe" for fetching paginated data. It does no
26+
* work until the iterator() method is called (e.g., by a for-each loop).
27+
*
28+
* Each call to iterator() creates a new, independent PagedIterator instance,
29+
* ensuring thread-safety and isolation of pagination state.
30+
*
31+
* @param <T> The type of item in the collection (e.g., User)
32+
*/
33+
publicclassPagedIterable<T> implementsIterable<T>{
34+
35+
/**
36+
* The "strategy" or "recipe" for fetching one page.
37+
* The String input is the 'nextUrl' (or null for the first page).
38+
* The ApiResponse output contains the page of results.
39+
*/
40+
privatefinalFunction<String, ApiResponse<List<T>>> pageFetcher;
41+
42+
/**
43+
* Constructs a new PagedIterable with the given page fetching strategy.
44+
*
45+
* @param pageFetcher A function that takes a next URL (or null for the first page)
46+
* and returns an ApiResponse containing a list of items and headers.
47+
*/
48+
publicPagedIterable(Function<String, ApiResponse<List<T>>> pageFetcher){
49+
this.pageFetcher = pageFetcher;
50+
}
51+
52+
/**
53+
* Creates a new, stateful iterator.
54+
* This is the key to thread-safety: every loop gets its
55+
* own private iterator to manage its own private state.
56+
*
57+
* @return A new PagedIterator instance for this iteration.
58+
*/
59+
@Override
60+
publicIterator<T> iterator(){
61+
returnnewPagedIterator<>(pageFetcher);
62+
}
63+
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/*
2+
* Copyright (c) 2025-Present, Okta, Inc.
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+
packagecom.okta.sdk.resource.client;
17+
18+
importcom.okta.commons.lang.Collections;
19+
20+
importjava.util.Iterator;
21+
importjava.util.LinkedList;
22+
importjava.util.List;
23+
importjava.util.Map;
24+
importjava.util.NoSuchElementException;
25+
importjava.util.Queue;
26+
importjava.util.function.Function;
27+
importjava.util.regex.Matcher;
28+
importjava.util.regex.Pattern;
29+
30+
/**
31+
* The new stateful, but *isolated*, iterator for pagination.
32+
*
33+
* This object is created for each loop and holds the state
34+
* (the next page URL and the current page's items) for that
35+
* loop *only*. It is not shared, making it thread-safe.
36+
*
37+
* @param <T> The type of item in the collection (e.g., User)
38+
*/
39+
publicclassPagedIterator<T> implementsIterator<T>{
40+
41+
// Regex to find the "next" link in a Link header
42+
privatestaticfinalPatternNEXT_LINK_PATTERN = Pattern.compile("<([^>]+)>\\s*rel=\"next\"");
43+
44+
privatefinalFunction<String, ApiResponse<List<T>>> pageFetcher;
45+
46+
// --- THIS IS THE ISOLATED STATE ---
47+
privateStringnextUrl = null;
48+
privatebooleanisFirstPage = true;
49+
privateQueue<T> itemBuffer = newLinkedList<>();
50+
// ---
51+
52+
/**
53+
* Constructs a new PagedIterator with the given page fetching strategy.
54+
*
55+
* @param pageFetcher A function that takes a next URL (or null for the first page)
56+
* and returns an ApiResponse containing a list of items and headers.
57+
*/
58+
publicPagedIterator(Function<String, ApiResponse<List<T>>> pageFetcher){
59+
this.pageFetcher = pageFetcher;
60+
}
61+
62+
@Override
63+
publicbooleanhasNext(){
64+
// 1. If we have items in our buffer, we're good.
65+
if (!itemBuffer.isEmpty()){
66+
returntrue;
67+
}
68+
69+
// 2. If it's the first page OR we have a next link,
70+
// we must try to fetch the next page.
71+
if (isFirstPage || nextUrl != null){
72+
fetchNextPage();
73+
}
74+
75+
// 3. After fetching, check the buffer again.
76+
// If it's still empty, we've reached the end.
77+
return !itemBuffer.isEmpty();
78+
}
79+
80+
@Override
81+
publicTnext(){
82+
// hasNext() ensures the buffer is populated if needed.
83+
if (!hasNext()){
84+
thrownewNoSuchElementException("No more items in pagination.");
85+
}
86+
// Return the next item from the current page's buffer.
87+
returnitemBuffer.poll();
88+
}
89+
90+
/**
91+
* The main engine. Calls the fetcher "strategy",
92+
* fills the buffer, and saves the next link.
93+
*/
94+
privatevoidfetchNextPage(){
95+
// Call the "strategy" function from API class
96+
ApiResponse<List<T>> response = pageFetcher.apply(isFirstPage ? null : nextUrl);
97+
98+
// Add all items from the response body to our buffer
99+
if (response.getBody() != null){
100+
itemBuffer.addAll(response.getBody());
101+
}
102+
103+
// Update our *private* state with the next link from the headers
104+
this.nextUrl = parseNextLinkFromHeaders(response.getHeaders());
105+
this.isFirstPage = false;
106+
}
107+
108+
/**
109+
* Helper to parse the 'Link' header for rel="next"
110+
*
111+
* @param headers The response headers map
112+
* @return The next URL if found, null otherwise
113+
*/
114+
privateStringparseNextLinkFromHeaders(Map<String, List<String>> headers){
115+
if (headers == null) returnnull;
116+
117+
// Try both "Link" and "link" as HTTP headers can vary in case
118+
List<String> linkHeaders = headers.get("Link");
119+
if (Collections.isEmpty(linkHeaders)){
120+
linkHeaders = headers.get("link");
121+
}
122+
if (Collections.isEmpty(linkHeaders)){
123+
returnnull;
124+
}
125+
126+
for (StringlinkHeader : linkHeaders){
127+
Matchermatcher = NEXT_LINK_PATTERN.matcher(linkHeader);
128+
if (matcher.find()){
129+
Stringurl = matcher.group(1);
130+
returnurl; // The URL
131+
}
132+
}
133+
134+
returnnull; // No "next" link found
135+
}
136+
}

0 commit comments

Comments
(0)