Step 4: Adding users and custom playlists

Enhance the Playlist application to store user playlists.

A music service needs the ability to allow individual users to log in and create playlists. You will now enhance the Playlist application to implement user authentication and custom playlists.

Changes to the Playlist schema 

A new users table holds the usernames and passwords for user authentication. We also need a place to store the playlist names for a user. Users to playlist names is a one-to-many relationship in standard data-modeling: one user can have many playlist names. In a relational database, a one-to-many relationship is usually handled by creating a playlist table with a foreign key to the user ID. In Cassandra, we will use a set<text> collection type in the users table to store the playlist names. We are using the set collection type to ensure that the playlist names are unique.

users
username text PK
password text  
playlist_names set<text>  
Note: To simplify the example application the passwords are stored unencrypted in the table, which is not recommended for any real, production application, which typically interface with an authentication service.
Note: Embedded collection types like set, map, and list have size limitations. See the CQL documentation for a discussion of when to use collection types.

The playlist is stored in a separate table, playlist_tracks.

create table playlist_tracks (username text, 
playlist_name text, 
sequence_no timestamp, 
artist text, 
track_name text, 
genre text, 
track_length_in_seconds int, 
track_id UUID, 
primary key ((username, playlist_name), sequence_no ));
playlist_tracks
username text PK
playlist_name text PK
sequence_no timestamp PK
artist text  
track_name text  
genre text  
track_length_in_seconds int  
track_id UUID  

The sequence_no column is of the data type timestamp. Using the sequence number as a timestamp allows us to make sure the tracks appear in the correct order. In other words, the sequence of tracks is treated like time series data, where the each successive track has a later timestamp. This simplifies the ordering of the tracks because we don't need to read the sequence number of the last track added to the playlist.

The primary key for playlist_tracks is also different than our other compound primary keys used in Playlist. The composite partition key is made up of username and playlist_name, which creates one playlist per partition. The primary key is made up of that composite partition key and sequence_no to uniquely identify the track.

Adding the user model 

Users are managed with a new model class, playlist.model.UserDAO. UserDAO contains add and delete methods for creating and removing users.

public static UserDAO addUser(String username, String password) throws UserExistsException {

  String queryText = "INSERT INTO users (username, password) values (?, ?) IF NOT EXISTS";

  PreparedStatement preparedStatement = getSession().prepare(queryText);

  // Because we use an IF NOT EXISTS clause, we get back a result set with 1 row containing 1 boolean column called "[applied]"
  ResultSet resultSet = getSession().execute(preparedStatement.bind(username, password));

  // Determine if the user was inserted.  If not, throw an exception.
  boolean userGotInserted = resultSet.one().getBool("[applied]");

  if (!userGotInserted) {
    throw new UserExistsException();
  }

  // Return the new user so the caller can get the userid
  return new UserDAO(username, password);

}

public void deleteUser() {
  SimpleStatement simpleStatement = new SimpleStatement("DELETE FROM users where username = '"
          + this.username + "'");

  // Delete users with CL = Quorum
  simpleStatement.setConsistencyLevel(ConsistencyLevel.QUORUM);
  getSession().execute(simpleStatement);

}

There is a finder method getUser used to retrieve existing users. Cassandra has tunable consistency that allows you to set the consistency level of the query when it executes. User data often requires a higher consistency level when executing queries than other types of data. For example, if a user has been removed, you do not want replica nodes to allow that user to log in between the time the user was removed and the cluster's data is updated throughout the cluster's replica nodes. In this case, we set the consistency level to QUORUM.

private static UserDAO getUser(String username) {

  String queryText = "SELECT * FROM users where username = ?";
          + username + "'";
  PreparedStatement preparedStatement = getSession().prepare(queryText);
  BoundStatement boundStatement = preparedStatement.bind(username);
  boundStatement.setConsistencyLevel(ConsistencyLevel.QUORUM);
  Row userRow = getSession().execute(boundStatement).one();

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

  return new UserDAO(userRow);

}

The QUORUM consistency level ensures that a sufficient number of replica nodes in a cluster agree that the returned data is up-to-date. The number of nodes that make a quorum depends on the replication factor of the cluster. For example, in a single data center cluster with a replication factor of 3, 2 replica nodes must respond if the query has a consistency level of QUORUM. While our cluster is a single-node cluster so all the data is stored on one node, we can easily modify Cassandra to run on a production cluster where the consistency levels of our queries impacts the performance and security of our application.

The validateLogin method authenticates the user by checking the username and password. The validateLogin method first calls the getUser method to retrieve the user data, then makes sure the passwords match.

public static UserDAO validateLogin(String username, String password) throws UserLoginException {

  UserDAO user = getUser(username);
  if (user == null || !user.password.contentEquals(password)) {
    throw new UserLoginException();
  }

  return user;
}

If the passwords don't match, a simple runtime exception, playlist.exceptions.UserLoginException is thrown.

Adding the playlist model 

The playlist.model.PlaylistDAO class handles creating, retrieving, modifying, and deleting user playlists, and exposes the following methods.

Methods in PlaylistDAO
Method name Description
createPlaylist Creates a new playlist for a user with the specified name.
deletePlayList Delete the playlist.
getPlaylistForUser A static finder method that retrieves the tracks in the specified playlist.
deleteTrackFromPlaylist Remove the specified track from the playlist.
addTrackToPlaylist Add the specified track to the playlist.

The CQL queries in createPlaylist, deleteTrackFromPlaylist, getPlaylistForUser, and addTrackToPlaylist are similar to the queries in the other model classes. The query in deletePlaylist, however, is a batch query, using the BEGIN BATCH and APPLY BATCH keywords to combine multiple queries so they get applied at the same time.

public void deletePlayList() {

  // Change single quotes to a pair of single quotes for escaping into the database
  String fixed_playlist_name = this.playlist_name.replace("'","''");

  PreparedStatement preparedStatement = getSession().prepare("BEGIN BATCH " +
          "UPDATE users set playlist_names = playlist_names - {'" + fixed_playlist_name + "'} WHERE username = ? " +
          "DELETE FROM playlist_tracks WHERE username = ? and playlist_name = ? " +
          "APPLY BATCH;");

  BoundStatement bs = preparedStatement.bind(this.username, this.username, this.playlist_name);

  getSession().execute(bs);

}

The users and playlist_tracks tables must both be modified if a playlist is deleted, so the queries are batched together to ensure the tables are updated at the same time. The queries are in between the BEGIN BATCH and APPLY BATCH keywords.

Adding the user controller 

The new playlist.controller.LoginServlet class handles user authentication and creation. HTTP POST requests are either user login attempts or user creation events. The doPost method checks the request headers to see what button the user pressed and calls the private doLogin or doCreateUser methods accordingly.

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

  // User creation and login is via the post method.  Logout is with a get.

  String button = request.getParameter("button");
  button = button == null ? "" : button;

  if (button.contentEquals("login")) {
    doLogin(request, response);
  } else if (button.contentEquals("newAccount")) {
    doCreateUser(request, response);
  }

}

The doLogin method checks to make sure the username passed in from the request headers is not empty, and then authenticates the user by creating a new UserDAO instance by calling UserDAO.validateLogin. If authentication fails, the UserLoginException is caught and the user is redirect to the login page to enter their credentials again. If authentication succeeds, the user is forwarded to the playlist page.

private void doLogin  (HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
  String username = request.getParameter("username");
  String password = request.getParameter("password");

  if (username.isEmpty()) {
    request.setAttribute("error", "Username Can Not Be Blank");
    getServletContext().getRequestDispatcher("/login.jsp").forward(request, response);
    return;

  }

  try {
    UserDAO user = UserDAO.validateLogin(username, password);
    HttpSession httpSession = request.getSession(true);
    httpSession.setAttribute("user", user);

  } catch (UserLoginException e) {

    // Go back to the user screen with an error

    request.setAttribute("error", "Username or Password is Invalid");
    getServletContext().getRequestDispatcher("/login.jsp").forward(request, response);
    return;
  }

  response.sendRedirect("playlists");

}

The doCreateUser attempts to create a new user with the specified username and password. After checking to make sure the username is not empty, a new UserDAO instance is created by calling UserDAO.addUser. If the username already exists, the playlist.exceptions.UserExistsException is caught and the user is forwarded back to the login page. If creating the new user succeeds, the user is forwarded to the playlist page.

private void doCreateUser  (HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
  String username = request.getParameter("username");
  String password = request.getParameter("password");

  if (username.isEmpty()) {
    request.setAttribute("error", "Username Can Not Be Blank");
    getServletContext().getRequestDispatcher("/login.jsp").forward(request, response);
    return;
  }

  HttpSession httpSession = request.getSession(true);

  // Add the user.  If it's successful, create a login session for it
  try {
    UserDAO newUser = UserDAO.addUser(username, password);


    // Create the user's login session so this application recognizes the user as having logged in
    httpSession.setAttribute("user", newUser);

  } catch (UserExistsException e) {

    // Go back to the user screen with an error

    request.setAttribute("error", "User Already Exists");
    getServletContext().getRequestDispatcher("/login.jsp").forward(request, response);
    return;

  }

  response.sendRedirect("playlists");

}

User logout events are handled by the doGet method, which responds to HTTP GET requests. If the user pressed the logout button, the private doLogout method is called.

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

  StatisticsDAO.increment_counter("page hits: login");

  String button = request.getParameter("button");
  button = button == null ? "" : button;

  if (button.contentEquals("logout")) {
    doLogout(request, response);
  } else {
    getServletContext().getRequestDispatcher("/login.jsp").forward(request,response);
  }
}

Adding the playlist controllers 

The playlist.controller.PlaylistsServlet and playlist.controller.PlaylistTracksServlet classes handle the requests from the views for managing playlists. The PlaylistsServlet class handles creating and deleting playlists for logged in users.

public class PlaylistsServlet extends HttpServlet {

  protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
     doAction(request, response);
  }

  protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
     doAction(request, response);
  }

  private void doAction(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    HttpSession httpSession = request.getSession(true);
    UserDAO user = (UserDAO) httpSession.getAttribute("user");

    // If we're not logged in, go to the login page
    if (user == null) {

      request.setAttribute("error", "Not Logged In");
      response.sendRedirect("login");
      return;

    }

    UserDAO userFromDB = UserDAO.getUser(user.getUsername());

    String button = request.getParameter("button");
    String playlist = request.getParameter("pl");


    if (button != null) {
      if (button.contentEquals("deletePlaylist")) {

        // Delete the playlist
        new PlaylistDAO(user.getUsername(), playlist).deletePlayList();

        // Force a re-read in this case
        response.sendRedirect("playlists");
        return;

      } else if (button.contentEquals("Add")) {
        if (playlist != null) {
          doAddPlaylist(userFromDB, playlist);
        }
      }
    }

    request.setAttribute("username", userFromDB.getUsername());
    request.setAttribute("playlist_names", userFromDB.getPlaylist_names());
    getServletContext().getRequestDispatcher("/playlists.jsp").forward(request,response);

  }

  private void doAddPlaylist(UserDAO user, String playlistName) {
    PlaylistDAO.createPlayList(user, playlistName);
  }

}

The PlaylistTracksServlet handles adding and deleting tracks from a particular playlist.

public class PlaylistTracksServlet extends HttpServlet {

  protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {

    // We add playlists with a post method

    HttpSession httpSession = request.getSession(true);

    String button = request.getParameter("button");
    String playlist_name = request.getParameter("pl");
    UserDAO user = (UserDAO) httpSession.getAttribute("user");

    PlaylistDAO playlist = PlaylistDAO.getPlaylistForUser(user.getUsername(), playlist_name);

    if (button != null) {
      if (button.contentEquals("addTrack")) {
        UUID track_id = UUID.fromString(request.getParameter("track_id"));
        doAddPlaylistTrack(playlist, track_id);
      }
    }

    request.setAttribute("username", user.getUsername());
    request.setAttribute("playlist", playlist);
    getServletContext().getRequestDispatcher("/playlist_tracks.jsp").forward(request,response);
  }

  protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
    HttpSession httpSession = request.getSession(true);
    UserDAO user = (UserDAO) httpSession.getAttribute("user");

    // If we're not logged in, go to the login page
    if (user == null) {

      request.setAttribute("error", "Not Logged In");
      response.sendRedirect("login");
      return;
    }

    //
    // Initialize the parameters that are returned from the web page
    //

    String playlist_name = request.getParameter("pl");
    PlaylistDAO playlist = PlaylistDAO.getPlaylistForUser(user.getUsername(), playlist_name);

    String button = request.getParameter("button");
    String deleteTrack = request.getParameter("deleteTrack");

    //
    // If a button was pressed, carry out the button's action
    //

    if (deleteTrack != null) {

      // Delete one track
        long sequence_no = Long.parseLong(deleteTrack);
        doDeleteTrack(playlist, sequence_no);
    }

    request.setAttribute("username", user.getUsername());
    request.setAttribute("playlist", playlist);
    getServletContext().getRequestDispatcher("/playlist_tracks.jsp").forward(request,response);

  }

  void doAddPlaylistTrack(PlaylistDAO playlist, UUID track_id) throws ServletException {
    // Grab the PlaylistTrack information from the DB
    TracksDAO track = TracksDAO.getTrackById(track_id);

    PlaylistDAO.PlaylistTrack newPlaylistTrack = new PlaylistDAO.PlaylistTrack(track);
    try {
      playlist.addTrackToPlaylist(newPlaylistTrack);
    } catch (Exception e) {
      throw new ServletException("Couldn't add track to playlist");
    }
  }

  void doDeleteTrack(PlaylistDAO playlist, long sequence_no) {
      playlist.deleteTrackFromPlaylist(sequence_no);
  }

}

Changes from Step 3 

To see all code changes from the step3 branch, enter the following command in a terminal in the playlist directory:

git diff step3..step4