Skip to content

Commit

Permalink
feature: paginator tweaks and unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
phpinhei-te committed Oct 7, 2024
1 parent 1e2a71e commit bf06b6d
Show file tree
Hide file tree
Showing 10 changed files with 367 additions and 42 deletions.
4 changes: 2 additions & 2 deletions core/client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ waiting the appropriate amount of time. E.g:
class Example {
private static final ApiClient apiClient = NativeApiClient
.builder()
.baseUri("https://api.stg.thousandeyes.com")
.baseUri("https://api.thousandeyes.com")
.bearerToken("<token>")
.build();

Expand Down Expand Up @@ -50,7 +50,7 @@ import java.util.stream.Collectors;
class Example {
private static final ApiClient apiClient = NativeApiClient
.builder()
.baseUri("https://api.stg.thousandeyes.com")
.baseUri("https://api.thousandeyes.com")
.bearerToken("<token>")
.build();

Expand Down
1 change: 1 addition & 0 deletions core/client/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ dependencies {
annotationProcessor "org.projectlombok:lombok:1.18.30"
testAnnotationProcessor "org.projectlombok:lombok:1.18.30"

testImplementation project(":administrative")
testImplementation "org.junit.jupiter:junit-jupiter:$junitVersion"
testImplementation "org.mockito:mockito-core:$mockitoVersion"
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,8 @@ public Paginator(PaginatedApiCall<R> apiCall, Function<R, List<T>> dataExtractor
this.dataExtractor = dataExtractor;
}

private String extractCursor(String href) {
if (href == null) {
return null;
}

var matcher = CURSOR_PATTERN.matcher(href);
if (matcher.find()) {
return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8);
}

return null;
}

@Override public Iterator<T> iterator() {
@Override
public Iterator<T> iterator() {
return new PaginatorIterator();
}

Expand All @@ -49,32 +37,34 @@ public Stream<T> stream() {

private class PaginatorIterator implements Iterator<T> {
private String cursor = null;
private Iterator<T> currentBatchIterator = null;
private boolean hasNextBatch = true;
private Iterator<T> currentPageIterator = null;
private boolean hasNextPage = true;

@Override
public boolean hasNext() {
if (currentBatchIterator == null || !currentBatchIterator.hasNext()) {
if (hasNextBatch) {
fetchNextBatch();
}
if (!currentPageHasNext() && hasNextPage) {
fetchNextPage();
}
return currentBatchIterator != null && currentBatchIterator.hasNext();
return currentPageHasNext();
}

@Override
public T next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
return currentBatchIterator.next();
return currentPageIterator.next();
}

private boolean currentPageHasNext() {
return currentPageIterator != null && currentPageIterator.hasNext();
}

private void fetchNextBatch() {
private void fetchNextPage() {
try {
R result = apiCall.call(cursor);
List<T> currentBatch = dataExtractor.apply(result);
currentBatchIterator = currentBatch.iterator();
List<T> currentPage = dataExtractor.apply(result);
currentPageIterator = currentPage.iterator();

var clazz = result.getClass();
var getLinks = clazz.getMethod("getLinks");
Expand All @@ -83,19 +73,35 @@ private void fetchNextBatch() {
var getNext = links.getClass().getMethod("getNext");
var next = getNext.invoke(links);

if (next != null) {
var getHref = next.getClass().getMethod("getHref");
String nextHref = (String) getHref.invoke(next);
cursor = extractCursor(nextHref);
}
else {
hasNextBatch = false;
cursor = extractCursor(next);
if (cursor == null) {
hasNextPage = false;
}
}
catch (NoSuchMethodException | InvocationTargetException | IllegalAccessException |
ApiException e) {
throw new RuntimeException("Error fetching next batch", e);
throw new RuntimeException("Error fetching next page", e);
}
}

private String extractCursor(Object next)
throws InvocationTargetException, IllegalAccessException, NoSuchMethodException
{
if (next != null) {
var getHref = next.getClass().getMethod("getHref");
String href = (String) getHref.invoke(next);

if (href == null) {
return null;
}

var matcher = CURSOR_PATTERN.matcher(href);
if (matcher.find()) {
return URLDecoder.decode(matcher.group(1), StandardCharsets.UTF_8);
}
}
return null;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

package com.thousandeyes.sdk;
package com.thousandeyes.sdk.client;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand All @@ -36,12 +36,6 @@
import org.mockito.Mock;
import org.mockito.Mockito;

import com.thousandeyes.sdk.client.ApiClient;
import com.thousandeyes.sdk.client.ApiException;
import com.thousandeyes.sdk.client.ApiRequest;
import com.thousandeyes.sdk.client.ApiResponse;
import com.thousandeyes.sdk.client.RateLimitDecorator;



public class RateLimitDecoratorTest {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.thousandeyes.sdk.pagination;

import static com.thousandeyes.sdk.serialization.JSON.getDefault;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoMoreInteractions;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.NoSuchElementException;
import java.util.stream.Stream;

import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;

import com.fasterxml.jackson.databind.ObjectMapper;

import com.thousandeyes.sdk.account.management.administrative.UserEventsApi;
import com.thousandeyes.sdk.account.management.administrative.model.AuditUserEvents;
import com.thousandeyes.sdk.account.management.administrative.model.UserEvent;
import com.thousandeyes.sdk.client.ApiException;



public class PaginatorTest {

private static UserEventsApi api;
private static AuditUserEvents withNextLink = null;
private static AuditUserEvents noNextLink = null;
private static final ObjectMapper mapper = getDefault().getMapper();

@BeforeAll
public static void setup() throws IOException {
var withNextLinkJson = readJson("with-next-link.json");
var noNextLinkJson = readJson("no-next-link.json");

withNextLink = mapper.readValue(withNextLinkJson, AuditUserEvents.class);
noNextLink = mapper.readValue(noNextLinkJson, AuditUserEvents.class);
}

@BeforeEach
public void clear() {
api = Mockito.mock(UserEventsApi.class);
}

@Test
void shouldCallNextLinkWhenNextLinkExists() throws ApiException {
var aid = "1";
var window = "1w";
var cursor = "b2Zmc2V0PTUwMTIzMTc5";
mockApiResponse(withNextLink, aid, window, null);
mockApiResponse(noNextLink, aid, window, cursor);

var paginator = buildPaginator(aid, window);
var elements = paginator.stream().toList();

assertEquals(4, elements.size());
verify(api).getUserEvents(aid, false, window, null, null, null);
verify(api).getUserEvents(aid, false, window, null, null, cursor);
verifyNoMoreInteractions(api);
}

@ParameterizedTest
@MethodSource("provideNoNextLinkResponses")
void shouldNotMakeExtraCallsWhenThereIsNoNextLink(AuditUserEvents response)
throws ApiException
{
var aid = "2";
var window = "2d";
mockApiResponse(response, aid, window, null);

var paginator = buildPaginator(aid, window);
var elements = paginator.stream().toList();

assertEquals(2, elements.size());
verify(api).getUserEvents(aid, false, window, null, null, null);
verifyNoMoreInteractions(api);
}

@Test
void shouldPropagateExceptionWhenApiThrowsException() throws ApiException {
var aid = "3";
var window = "3h";
doThrow(ApiException.class)
.when(api)
.getUserEvents(aid, false, window, null, null, null);

var paginator = buildPaginator(aid, window);
var exception = assertThrows(RuntimeException.class, () -> paginator.iterator().next());

assertEquals("Error fetching next page", exception.getMessage());
assertEquals(ApiException.class, exception.getCause().getClass());
}

@Test
void shouldReturnNoElementsWhenEmptyResponse()
throws ApiException, IOException
{
var aid = "4";
var window = "4m";
var emptyResponseJson = readJson("empty-response.json");
var emptyResponse = mapper.readValue(emptyResponseJson, AuditUserEvents.class);
mockApiResponse(emptyResponse, aid, window, null);

var paginator = buildPaginator(aid, window);
var elements = paginator.stream().toList();
assertEquals(0, elements.size());
verify(api).getUserEvents(aid, false, window, null, null, null);
verifyNoMoreInteractions(api);
}

@Test
void shouldThrowNoSuchElementExceptionWhenNoNextElement()
throws ApiException, IOException
{
var aid = "5";
var window = "5s";
var emptyResponseJson = readJson("empty-response.json");
var emptyResponse = mapper.readValue(emptyResponseJson, AuditUserEvents.class);
mockApiResponse(emptyResponse, aid, window, null);

var paginator = buildPaginator(aid, window);
var iterator = paginator.iterator();

assertThrows(NoSuchElementException.class, iterator::next);
verify(api).getUserEvents(aid, false, window, null, null, null);
verifyNoMoreInteractions(api);
}

private static Stream<AuditUserEvents> provideNoNextLinkResponses() throws IOException {
var emptyNextLinkJson = readJson("empty-next-link.json");
var missingNextLinkCursorJson = readJson("missing-next-link-cursor.json");

var emptyNextLink = mapper.readValue(emptyNextLinkJson, AuditUserEvents.class);
var missingNextLinkCursor =
mapper.readValue(missingNextLinkCursorJson, AuditUserEvents.class);

return Stream.of(noNextLink, emptyNextLink, missingNextLinkCursor);
}

private Paginator<UserEvent, AuditUserEvents> buildPaginator(String aid, String window) {
return new Paginator<>(cursor -> api.getUserEvents(aid, false, window, null, null, cursor),
AuditUserEvents::getAuditEvents);
}

private void mockApiResponse(AuditUserEvents response, String aid, String window, String cursor)
throws ApiException
{
doReturn(response)
.when(api)
.getUserEvents(aid, false, window, null, null, cursor);
}

private static String readJson(String fileName) throws IOException {
return Files.readString(buildResourcesPath(fileName));
}

private static Path buildResourcesPath(String filePath) {
return Paths.get("src", "test", "resources", "pagination", filePath);
}
}
35 changes: 35 additions & 0 deletions core/client/src/test/resources/pagination/empty-next-link.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
{
"auditEvents": [
{
"accountGroupName": "group",
"aid": "1",
"event": "Login",
"date": "2024-10-04T08:43:59Z",
"ipAddress": "1.1.1.1",
"uid": "2",
"user": "User (user@user.com)",
"resources": [
{
"type": "name",
"name": "User"
}
]
},
{
"accountGroupName": "group",
"aid": "1",
"event": "Logout",
"date": "2024-10-04T08:42:56Z",
"ipAddress": "1.1.1.1",
"uid": "2",
"user": "User (user@user.com)"
}
],
"_links": {
"next": {
},
"self": {
"href": "https://api.com/v7/audit-user-events"
}
}
}
8 changes: 8 additions & 0 deletions core/client/src/test/resources/pagination/empty-response.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"auditEvents": [],
"_links": {
"self": {
"href": "https://api.com/v7/audit-user-events"
}
}
}
Loading

0 comments on commit bf06b6d

Please sign in to comment.