package com.example.paging;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import java.util.List;
public record PageResponse<T>(List<T> items, String nextUrl) {
@JsonCreator
public PageResponse(
@JsonProperty("data") List<T> items,
@JsonProperty("next") String nextUrl) {
this.items = items == null ? List.of() : items;
this.nextUrl = nextUrl;
}
public boolean hasNext() {
return nextUrl != null && !nextUrl.isBlank();
}
}
package com.example.paging;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.type.CollectionType;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
public class PagedApiClient {
private final HttpClient http;
private final ObjectMapper mapper;
public PagedApiClient(ObjectMapper mapper) {
this.mapper = mapper;
this.http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public <T> PageResponse<T> fetchPage(String url, Class<T> itemType) throws IOException {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
HttpResponse<String> response;
try {
response = http.send(request, HttpResponse.BodyHandlers.ofString());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted while fetching " + url, e);
}
if (response.statusCode() / 100 != 2) {
throw new IOException("Unexpected status " + response.statusCode() + " for " + url);
}
CollectionType listType = mapper.getTypeFactory()
.constructCollectionType(java.util.List.class, itemType);
var type = mapper.getTypeFactory()
.constructParametricType(PageResponse.class, listType);
return mapper.readValue(response.body(), type);
}
}
package com.example.paging;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.Iterator;
import java.util.NoSuchElementException;
import java.util.Spliterator;
import java.util.Spliterators;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
public class PageIterator<T> implements Iterator<PageResponse<T>> {
private final PagedApiClient client;
private final Class<T> itemType;
private String nextUrl;
public PageIterator(PagedApiClient client, Class<T> itemType, String startUrl) {
this.client = client;
this.itemType = itemType;
this.nextUrl = startUrl;
}
@Override
public boolean hasNext() {
return nextUrl != null && !nextUrl.isBlank();
}
@Override
public PageResponse<T> next() {
if (!hasNext()) {
throw new NoSuchElementException();
}
try {
PageResponse<T> page = client.fetchPage(nextUrl, itemType);
nextUrl = page.hasNext() ? page.nextUrl() : null;
return page;
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public static <T> Stream<T> streamAll(PagedApiClient client, Class<T> itemType, String startUrl) {
PageIterator<T> pages = new PageIterator<>(client, itemType, startUrl);
Stream<PageResponse<T>> pageStream = StreamSupport.stream(
Spliterators.spliteratorUnknownSize(pages, Spliterator.ORDERED | Spliterator.NONNULL),
false);
return pageStream.flatMap(page -> {
Deque<T> buffer = new ArrayDeque<>(page.items());
return buffer.stream();
});
}
}
This snippet shows how to consume a cursor-style paginated third-party API in Java by following the next link each page returns, exposing the whole result set as a lazy Stream so callers never have to think about page boundaries. It splits into three collaborating files: a typed page model, an HTTP client that fetches one page at a time, and an Iterator/Spliterator that walks the pages on demand.
In PageResponse, a single page is modeled as an immutable record with the items payload and a nullable nextUrl. This mirrors how many APIs work: rather than exposing total counts or numeric offsets, they hand back an opaque link to the next slice, which is more robust when the underlying data changes between requests. The hasNext() helper and the Jackson @JsonProperty on the next field keep the JSON contract explicit while leaving the record clean.
PagedApiClient owns the low-level concern of turning a URL into a PageResponse. It uses the built-in java.net.http.HttpClient, sends a synchronous GET, checks the status, and deserializes the body with a shared ObjectMapper. The important design choice is that fetchPage takes an absolute URL — the same method retrieves the first page and every subsequent one, because the API itself supplies the follow-up links. A non-2xx response is turned into an IOException so failures surface loudly instead of silently yielding empty pages.
PageIterator is where laziness lives. It holds only the URL of the next page to fetch, not the whole dataset. Each call to next() calls back into the client, buffers that page's items into a Deque, and remembers nextUrl for later. hasNext() drives the fetch, so pages are pulled only as the consumer asks for them. The static streamAll wraps the iterator in a Spliterator via Spliterators.spliteratorUnknownSize and flatMaps each page's items into one flat Stream<T>.
The payoff is that a caller can write streamAll(...).limit(50) and the client stops making HTTP calls as soon as fifty elements are produced, never fetching the tail of a huge result set. The main trade-offs are that network errors are wrapped in an unchecked UncheckedIOException to fit the Iterator contract, and that the stream is single-pass. This pattern fits any API using link-based or cursor-based pagination where prefetching everything would be wasteful.
Related snips
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
package com.example.starter.config;
import com.example.starter.properties.CustomProperties;
import com.example.starter.service.CustomService;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
Custom Spring Boot starters
import React from "react";
type FallbackProps = {
error: Error;
reset: () => void;
};
React Error Boundary + error reporting hook
package com.example.demo.config;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
Messaging with Apache Kafka
Share this code
Here's the card — post it anywhere.