/*
* Copyright (C) 2012-2015 DataStax Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.datastax.driver.examples.paging;
import com.datastax.driver.core.*;
import com.google.common.collect.Lists;
import com.sun.net.httpserver.HttpServer;
import org.glassfish.hk2.utilities.binding.AbstractBinder;
import org.glassfish.jersey.jdkhttp.JdkHttpServerFactory;
import org.glassfish.jersey.server.ResourceConfig;
import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.ws.rs.*;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.UriBuilder;
import javax.ws.rs.core.UriInfo;
import java.io.IOException;
import java.net.URI;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* A stateless REST service (backed by
* <a href="https://jersey.java.net/">Jersey</a>,
* <a href="https://hk2.java.net/">HK2</a> and
* the JDK HttpServer) that displays paginated results for a CQL query.
* <p/>
* Conversion to and from JSON is made through
* <a href="https://jersey.java.net/documentation/latest/media.html#json.jackson">Jersey Jackson providers</a>.
* <p/>
* Navigation is bidirectional, and you can jump to a random page (by modifying the URL).
* Cassandra does not support offset queries (see https://issues.apache.org/jira/browse/CASSANDRA-6511), so we emulate
* it by restarting from the beginning each time, and iterating through the results until we reach the requested page.
* This is fundamentally inefficient (O(n) in the number of rows skipped), but the tradeoff might be acceptable for some
* use cases; for example, if you show 10 results per page and you think users are unlikely to browse past page 10,
* you only need to retrieve at most 100 rows.
* <p/>
* Preconditions:
* - a Cassandra cluster is running and accessible through the contacts points identified by CONTACT_POINTS and
* CASSANDRA_PORT;
* - port HTTP_PORT is available.
* <p/>
* Side effects:
* - creates a new keyspace "examples" in the cluster. If a keyspace with this name already exists, it will be reused;
* - creates a table "examples.random_paging_rest_ui". If it already exists, it will be reused;
* - inserts data in the table;
* - launches a REST server listening on HTTP_PORT.
*/
public class RandomPagingRestUi {
static final String[] CONTACT_POINTS = {"127.0.0.1"};
static final int CASSANDRA_PORT = 9042;
static final int HTTP_PORT = 8080;
static final int ITEMS_PER_PAGE = 10;
// How many rows the driver will retrieve at a time.
// This is set artificially low for the sake of this example. Unless your rows are very large, you can probably use
// a much higher value (the driver's default is 5000).
static final int FETCH_SIZE = 60;
static final URI BASE_URI = UriBuilder.fromUri("http://localhost/").path("").port(HTTP_PORT).build();
public static void main(String[] args) throws Exception {
Cluster cluster = null;
try {
cluster = Cluster.builder()
.addContactPoints(CONTACT_POINTS).withPort(CASSANDRA_PORT)
.build();
Session session = cluster.connect();
createSchema(session);
populateSchema(session);
startRestService(session);
} finally {
if (cluster != null) cluster.close();
}
}
// Creates a table storing videos by users, in a typically denormalized way
private static void createSchema(Session session) {
session.execute("CREATE KEYSPACE IF NOT EXISTS examples " +
"WITH replication = {'class': 'SimpleStrategy', 'replication_factor': 1}");
session.execute("CREATE TABLE IF NOT EXISTS examples.random_paging_rest_ui(" +
"userid int, username text, " +
"added timestamp, " +
"videoid int, title text, " +
"PRIMARY KEY (userid, added, videoid)" +
") WITH CLUSTERING ORDER BY (added DESC, videoid ASC)");
}
private static void populateSchema(Session session) {
// 3 users
for (int i = 0; i < 3; i++) {
// 49 videos each
for (int j = 0; j < 49; j++) {
int videoid = i * 100 + j;
session.execute("INSERT INTO examples.random_paging_rest_ui (userid, username, added, videoid, title) VALUES (?, ?, ?, ?, ?)",
i, "user " + i, new Date(j * 100000), videoid, "video " + videoid);
}
}
}
// starts the REST server using JDK HttpServer (com.sun.net.httpserver.HttpServer)
private static void startRestService(Session session) throws IOException, InterruptedException {
final HttpServer server = JdkHttpServerFactory.createHttpServer(BASE_URI, new VideoApplication(session), false);
final ExecutorService executor = Executors.newSingleThreadExecutor();
server.setExecutor(executor);
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run() {
System.out.println();
System.out.println("Stopping REST Service");
server.stop(0);
executor.shutdownNow();
System.out.println("REST Service stopped");
}
}));
server.start();
System.out.println();
System.out.printf("REST Service started on http://localhost:%d/users, press CTRL+C to stop%n", HTTP_PORT);
System.out.println("To explore this example, start with the following request and walk from there:");
System.out.printf("curl -i http://localhost:%d/users/1/videos%n", HTTP_PORT);
System.out.println();
Thread.currentThread().join();
}
/**
* Configures the REST application and handles injection of custom objects, such
* as the driver session.
* <p/>
* This is also the place where you would normally configure JSON serialization, etc.
* <p/>
* Note that in this example, we rely on the automatic discovery and configuration of
* Jackson through {@code org.glassfish.jersey.jackson.JacksonFeature}.
*/
public static class VideoApplication extends ResourceConfig {
public VideoApplication(final Session session) {
super(UserService.class);
// AbstractBinder is provided by HK2
register(new AbstractBinder() {
@Override
protected void configure() {
bind(session).to(Session.class);
}
});
}
}
/**
* A typical REST service, handling requests involving users.
* <p/>
* Typically, this service would contain methods for listing and searching for users,
* and methods to retrieve user details. Here, for brevity,
* only one method, listing videos by user, is implemented.
*/
@Singleton
@Path("/users")
@Produces("application/json")
public static class UserService {
@Inject
private Session session;
@Context
private UriInfo uri;
private PreparedStatement videosByUser;
private Pager pager;
@PostConstruct
@SuppressWarnings("unused")
public void init() {
this.pager = new Pager(session, ITEMS_PER_PAGE);
this.videosByUser = session.prepare("SELECT videoid, title, added FROM examples.random_paging_rest_ui WHERE userid = ?");
}
/**
* Returns a paginated list of all the videos created by the given user.
*
* @param userid the user ID.
* @param page the page to request, or {@code null} to get the first page.
*/
@GET
@Path("/{userid}/videos")
public UserVideosResponse getUserVideos(@PathParam("userid") int userid, @QueryParam("page") Integer page) {
Statement statement = videosByUser.bind(userid).setFetchSize(FETCH_SIZE);
if (page == null) page = 1;
ResultSet rs = pager.skipTo(statement, page);
List<UserVideo> videos;
boolean empty = rs.isExhausted();
if (empty) {
videos = Collections.emptyList();
} else {
int remaining = ITEMS_PER_PAGE;
videos = Lists.newArrayListWithExpectedSize(remaining);
for (Row row : rs) {
UserVideo video = new UserVideo(
row.getInt("videoid"),
row.getString("title"),
row.getTimestamp("added"));
videos.add(video);
if (--remaining == 0)
break;
}
}
URI previous = (page == 1) ? null
: uri.getAbsolutePathBuilder().queryParam("page", page - 1).build();
URI next = (empty) ? null
: uri.getAbsolutePathBuilder().queryParam("page", page + 1).build();
return new UserVideosResponse(videos, previous, next);
}
}
public static class UserVideosResponse {
private final List<UserVideo> videos;
private final URI previousPage;
private final URI nextPage;
public UserVideosResponse(List<UserVideo> videos, URI previousPage, URI nextPage) {
this.videos = videos;
this.previousPage = previousPage;
this.nextPage = nextPage;
}
@SuppressWarnings("unused")
public List<UserVideo> getVideos() {
return videos;
}
@SuppressWarnings("unused")
public URI getPreviousPage() {
return previousPage;
}
@SuppressWarnings("unused")
public URI getNextPage() {
return nextPage;
}
}
public static class UserVideo {
private final int videoid;
private final String title;
private final Date added;
public UserVideo(int videoid, String title, Date added) {
this.videoid = videoid;
this.title = title;
this.added = added;
}
@SuppressWarnings("unused")
public int getVideoid() {
return videoid;
}
public String getTitle() {
return title;
}
@SuppressWarnings("unused")
public Date getAdded() {
return added;
}
}
/**
* Helper class to emulate random paging.
* <p/>
* Note that it MUST be stateless, because it is cached as a field in our HTTP handler.
*/
static class Pager {
private final Session session;
private final int pageSize;
Pager(Session session, int pageSize) {
this.session = session;
this.pageSize = pageSize;
}
ResultSet skipTo(Statement statement, int displayPage) {
// Absolute index of the first row we want to display on the web page. Our goal is that rs.next() returns
// that row.
int targetRow = (displayPage - 1) * pageSize;
ResultSet rs = session.execute(statement);
// Absolute index of the next row returned by rs (if it is not exhausted)
int currentRow = 0;
int fetchedSize = rs.getAvailableWithoutFetching();
byte[] nextState = rs.getExecutionInfo().getPagingStateUnsafe();
// Skip protocol pages until we reach the one that contains our target row.
// For example, if the first query returned 60 rows and our target is row number 90, we know we can skip
// those 60 rows directly without even iterating through them.
// This part is optional, we could simply iterate through the rows with the for loop below, but that's
// slightly less efficient because iterating each row involves a bit of internal decoding.
while (fetchedSize > 0 && nextState != null && currentRow + fetchedSize < targetRow) {
statement.setPagingStateUnsafe(nextState);
rs = session.execute(statement);
currentRow += fetchedSize;
fetchedSize = rs.getAvailableWithoutFetching();
nextState = rs.getExecutionInfo().getPagingStateUnsafe();
}
if (currentRow < targetRow) {
for (@SuppressWarnings("unused") Row row : rs) {
if (++currentRow == targetRow) break;
}
}
// If targetRow is past the end, rs will be exhausted.
// This means you can request a page past the end in the web UI (e.g. request page 12 while there are only
// 10 pages), and it will show up as empty.
// One improvement would be to detect that and take a different action, for example redirect to page 10 or
// show an error message, this is left as an exercise for the reader.
return rs;
}
}
}