Scooter Sample Application: Twitterdemo

Introduction

Twitter is the most popular site to share and discover what is happening right now. The messages on Twitter are called tweets. We are on Twitter too. To find out the latest tweets of Scooter Framework, just click our Twitter account here. Twitter website was implemented by using Ruby-on-Rails.

Let's create a website which has some similar functionalities of Twitter. This exercise is only a demo to show how easily and rapidly we can implement a database-backed web application by using Java. Please be aware that we have no intention and interest of cloning Twitter.com.

We name our example site Twitterdemo.

Analysis

Actions

We plan to implement the following features organized by urls.

HTTP verb URL controller action model description login required
ANY /$username tweets user_tweets tweet display all tweets from $username N
ANY /home tweets followings_tweets tweet display all tweets from my followings Y
POST /tweets tweets create tweet create a new tweet Y
ANY /$username/followings accounts followings account display all people $username follows Y
ANY /$username/followers accounts followers account display all people who follow $username Y
POST /accounts/addFollowing accounts addFollowing account start to follow someone Y

Models

The domain model of twitterdemo is very simple. We only need three tables. Figure 1 shows all the models and their corresponding table structures.

Figure 1: Twitterdemo models and tables

We need an accounts table to hold all accounts. The accounts table contains username, password and some counters, such as number of tweets posted, number of followers and number of followings.

Relationships with other accounts are held in followships table. When you follow someone, you become a follower and the other person becomes following.

Table tweets stores messages (tweets) created by account owners.

In order to show relations in a better way, we split the accounts table into two blocks: followers and followings. followers represent all people who follow someone, while followings represent all people who are followed.

Figure 2: Twitterdemo relations

There are many associations among these tables. For example,

  • An account has many tweets.
  • A tweet belongs to an account.
  • A follower can chase after many other people.
  • A popular account can attract many followers.

We can easily translate the relationships in Figure 2 into model code.

public class Account extends ActiveRecord {
    public void registerRelations() {
        hasMany("chases", "mapping:id=follower_id; model:followship; reverse:follower; cascade:delete");
        hasManyThrough("followings", "chases");

        hasMany("attractions", "mapping:id=following_id; model:followship; reverse:following; cascade:delete");
        hasManyThrough("followers", "attractions");

        hasMany("tweets", "cascade:delete");
        hasManyThrough("followings_tweets", "chases", "order_by: tweets.created_at desc");
    }
}

public class Followship extends ActiveRecord {
    public void registerRelations() {
        belongsTo("follower", "model: account; counter_cache:followings_count");
        belongsTo("following", "model: account; counter_cache:followers_count");

        hasMany("followings_tweets", "model: tweet; mapping: following_id=account_id");
    }
}

public class Tweet extends ActiveRecord {
    public void validatesRecord() {
    	validators().validatesLengthMaximum("message", 140);
    }

    public void registerRelations() {
        belongsTo("account", "counter_cache:true");
    }
}
Account Model

For account model, there is a has-many association named "chases" with followship model. We need to indicate the target model name here because Scooter cannot detect the target model name from the association name by default naming convention.

The reverse association name is follower. This is also needed as Scooter cannot detect the reverse association name from "chases". Cascade here means that when an account is deleted, all of its records in followships table are also deleted.

An account can find all people it follows through the has-many-through association.

There is another has-many association named "attractions" with the followship model. An account can find all its followers through this has-many-through association.

The third has-many association is between accounts and tweets. And you can get a list of tweets from all people you are following by this has-many-through association. The tweets are going to be retrieved in decending orders of their timestamps.

Followship Model

In followship model, we define three associations.

A belongs-to association named "follower" with the account model. Again here we need to use the model keyword to indicate the target model name as Scooter cannot detect the target model name from the association name "follower" by default. The counter_cache keyword tells parent account model to record number of followings.

Another belongs-to association named "following" with the account model. The counter_cache keyword tells parent account model to record number of followers.

A has-many association with model tweet. This association helps to get a list of tweets from people the account follows.

Tweet Model

In tweet model, there is only one belongs-to association. The counter_cache key indicates that the parent account table should record number of tweets created by the account owner.

Notice that in the tweet model, we also add validations which restrict the maximum number of characters of a message to be no more than 140 characters.

Sample Data

For ease of development, I am going to put some sample data in our database.

I am going to create four user accounts: Scooter, Java, Rails and John. Each uses "demo" as password.

  • Scooter has two tweets and follows Java and Rails.
  • Java has one tweet and follows no one.
  • Rails has two tweets and follows no one.
  • John has two tweets and follows Scooter and Java.

Implementation

We follow the following steps to implement the site. These steps are true for almost all applications built by Scooter.

  1. Create Twitterdemo app: java -jar tools/create.jar twitterdemo
  2. Start up web server: java -jar tools/server.jar twitterdemo 8080
  3. Browse app website: http://localhost:8080/twitterdemo
  4. Generate code, or edit code
  5. Refresh browser
  6. Repeat the above two steps until the whole app is done

Implementing models

From our analysis above, we already have content for all models. We can either manually paste them into a file or use code generator first to create a template of a model and then paste the code above into it.

For example, for model tweet, we will open a terminal and type the following command:

> java -jar tools/generate.jar twitterdemo model tweet

The above command will create two files:

  1. a model src/twitterdemo/models/Tweet.java
  2. a test test/twitterdemo/models/TweetTest.java

We will then paste the content of tweet model into the generated Tweet.java class.

Implementing signon

Since this application requires a login screen, the first set of code you may want to generate at step 8 is a sign-in module. You just need to type the following command.

> java -jar tools/generate-signon.jar

This signon generator will create a signon controller, a login screen, a logout screen and a landing screen after successful login. Now if you refresh your browser, you will see a "Sign In" link on top of the action bar.

Twitterdemo Login Page

Click on the link and you will then see the login screen. If you don't enter username and password, the signon controller will reject your sign-in request.

Twitterdemo Login Failure Page

You can type anything for the username and password at this stage. After you sign in, you will see a main page.

Twitterdemo Main Page

Notice that after you sign in, the Sign In link on top of screen becomes Sign Out. Click on the Sign Out link, you will see a logout screen like this:

Twitterdemo Logout Page

Let's sign in again. There are instructions on what to do next on the main page.

Since we want to show a list of tweets from people whom the login user follows, this main screen is not what we want. Thus we follow the instruction on the main screen: implement the authenticate() method and remove the main.jsp file.

The following shows the generated SignonController:

package twitterdemo.controllers;

import static com.scooterframework.web.controller.ActionControl.*;

import com.scooterframework.orm.activerecord.ActiveRecord;
import com.scooterframework.security.LoginHelper;

/**
 * SignonController class handles signon related access.
 */
public class SignonController extends ApplicationController {

    static {
        filterManagerFor(SignonController.class).declareBeforeFilter("loginRequired", "only", "main");
        filterManagerFor(SignonController.class).declareBeforeFilter("validateInput", "only", "authenticate");
    }

    public String validateInput() {
        validators().validatesPresenceOf("username");
        validators().validatesPresenceOf("password");
        if (validationFailed()) {
            flash("error", "Please submit both username and password.");
            return redirectTo("/signon/login");
        }
        return null;
    }

    /**
     * main method
     */
    public String main() {
        return null;
    }

    /**
     * login method
     */
    public String login() {
        return null;
    }

    /**
     * Authenticates login request.
     */
    public String authenticate() {
        String username = p("username");
        String password = p("password");

        /************* Remove this block ************/
            LoginHelper.cacheLoggedInUserId(username);//Save the login user id to session
            return redirectTo("/signon/main");


        /************* Implement authentication logic below ***********
        ActiveRecord user = Account.findFirst("username=" + username + ", password=" + password);
        if (user != null) {
            LoginHelper.cacheLoggedInUser(user);//Save the login user to session
            LoginHelper.cacheLoggedInUserId(username);//Save the login user id to session
            return redirectTo("/signon/main");
        }

        flash("error", "Please login by using correct username and password.");
        return forwardTo("/signon/login");
        ************** Implement the above block ************/
    }

    /**
     * logout method
     */
    public String logout() {
        LoginHelper.userLogout();
        return null;
    }

    /**
     * loginRequired method (usually used in beforeFilter)
     */
    public String loginRequired() {
        if (!LoginHelper.isLoggedIn()) {
            flash("error", "You must be logged in to do that.");
            return redirectTo("/signon/login");
        }
        return null;
    }
}

This SignonController uses the authenticate() method to authenticate a login request. The authenticate() method is associated with a before filter which means that any login request has to be validated before invoking the authenticate() method. The default validation logic presented in the validateInput() method simply only validates if username and password are present in the request. If not, the request is redirected back to the login screen.

In reality, the authenticate() method should validate user credentials with some kind of authentication system.

Here the implementation of the authenticate() method simply uses database to validate a user login.

/**
 * Authenticates login request.
 */
public String authenticate() {
    String username = p("username");
    String password = p("password");

    ActiveRecord user = Account.findFirst("username='" + username + "' and password='" + password + "'");
    if (user != null) {
        LoginHelper.cacheLoggedInUser(user);//Save the login user to session
        LoginHelper.cacheLoggedInUserId(username);//Save the login user id to session
        return redirectTo("/tweets/followings_tweets");
    }

    flash("error", "Please login by using correct username and password.");
    return renderView("login");
}

This authenticate() method is not much different from the default authenticate() method in the generated SignonController.java. It simply retrieve the user record from database based on input values in the login request.

If a user record is found, that means the login request is a valid request. It then redirects the request to TweetsController's followings_tweets action to retrieve a list of tweets from people whom the login user follows.

If a user record is not found, that means the login request in invalid. The request is forwarded to the original login screen.

Our next step is obviously to implement a TweetsController which must have a followings_tweets action.

Implementing followings_tweets action

This action should return a list of tweets from all people the login user follows.

You can start with code generator to generate controller and model code as follows:

> java -jar tools/generate.jar twitterdemo controller tweets followings_tweets

This will create two files:

  1. a TweetsController with a followings_tweets method
  2. a followings_tweets view located in webapps/twitterdemo/WEB-INF/views/tweets/ directory

Now if you sign in, you will see this screen which is the generated followings_tweets view.

Twitterdemo Followings Tweets Page

You may wonder which route handles this /tweets/followings_tweets request redirected by the authenticate() method. Obviously it is the default route 0.

Now we just need to complete the followings_tweets action and view.

followings_tweets action in TweetsController.java
package twitterdemo.controllers;

import java.util.List;

import com.scooterframework.orm.activerecord.ActiveRecord;
import com.scooterframework.security.LoginHelper;

/**
 * TweetsController class handles tweets related access.
 */
public class TweetsController {
    /**
     * followings_tweets method
     */
    public String followings_tweets() {
        ActiveRecord loginUser = LoginHelper.loginUser();
        if (loginUser != null) {
            List tweets = loginUser.allAssociated("followings_tweets", "include:account").getRecords();
            setViewData("followings_tweets", tweets);
            setViewData("username", loginUser.getField("username"));
            setViewData("user", loginUser);
        }
    	return null;
    }
}
followings_tweets.jsp
<%@ page import="
        java.util.Date,
        java.util.Iterator,
        com.scooterframework.orm.sqldataexpress.object.RESTified,
        com.scooterframework.web.util.D,
        com.scooterframework.web.util.F,
        com.scooterframework.web.util.O,
        com.scooterframework.web.util.W"
%>

<%
for (Iterator it = O.iteratorOf("followings_tweets"); it.hasNext();) {
    RESTified tweet = (RESTified)it.next();
%>
    <b><%=W.labelLink(O.hp(tweet, "account.username"), "/" + O.hp(tweet, "account.username"))%></b> <%=O.hp(tweet, "message")%><br />
    <%=D.message((Date)O.getProperty(tweet, "created_at"))%><br />
    <br />
<%}%>

Notice that in the followings_tweets() method, we use keyword include. In this way, Scooter retrieves a list of tweets and their associated authors in one query. This is a typical example of eager loading.

Now if we login as John/demo, we will see the following:

Twitterdemo Followings Tweets Complete Page

There are totally three tweets from people John follows.

Our TweetsController is in a good shape, except one more requirement. In our analysis above, we decide that only login users can see a list of tweets from people they follow.

Let's refactor the TweetsController code by adding a before filter as follows:

login validation in TweetsController.java
package twitterdemo.controllers;

import java.util.List;

import com.scooterframework.orm.activerecord.ActiveRecord;
import com.scooterframework.security.LoginHelper;

/**
 * TweetsController class handles tweets related access.
 */
public class TweetsController {
    static {
        filterManagerFor(TweetsController.class).declareBeforeFilter(
            SignonController.class, "loginRequired", "only", "create, followings_tweets");
    }

    /**
     * followings_tweets method
     */
    public String followings_tweets() {
        ActiveRecord loginUser = LoginHelper.loginUser();
        if (loginUser != null) {
            List tweets = loginUser.allAssociated("followings_tweets", "include:account").getRecords();
            setViewData("followings_tweets", tweets);
            setViewData("username", loginUser.getField("username"));
            setViewData("user", loginUser);
        }
    	return null;
    }
}

Now if we type the following url in a browser's address bar without logging in first,

http://localhost:8080/twitterdemo/tweets/followings_tweets

we should see this screen:

Twitterdemo Signin Required Page

Implementing other actions

We are not going to show detailed implementation steps of the rest of actions as they are the same as we did for the followings_tweets action. You simply repeat steps 4 and 5 above until you see what you want on your browser screen.

Scooter has built-in support for two-column panels layout. In fact that is the default. Here is what you need to do to turn it on:

  • Implement webapps/twitterdemo/WEB-INF/views/layouts/includes/sidemenu.jsp
  • Remove comment around tag #sidemenu in webapps/twitterdemo/static/stylesheets/main.css

Please refer to source code files for more details. Here I am going to show some completed screens.

Home page Twitterdemo Home Page

Enter a tweet Twitterdemo Enter Tweet Page

Tweet created successfully Twitterdemo Tweet Created Page

You can see that on the right hand side, the number of tweets is changed from 2 to 3.

List of people follow John Twitterdemo Followers Page

List of people John follows Twitterdemo Followings Page

There are only two people John follows. Now let's follow one more person. Type http://localhost:8080/twitterdemo/Rails in browser's address bar and we get user Rails's home page.

Rails's home page Twitterdemo Rails's Home Page

After clicking on the Following This Person button link, we get this page:

John's home page Twitterdemo John's Home Page

On the right hand side of browser screen, the number of followings is changed from 2 to 3.

Click on the home link, you can see tweets from Rails.

Twitterdemo John's Home Page

You may not need to restart the embedded Jetty web server during the whole development process. I only restarted the sever once, because I was not able to save changes in main.css file unless I shut down the server.

How many lines of code we have

The amount of code of this application is not large. Here is a summary.

> java -jar tools/codestats.jar
    webapps/twitterdemo/WEB-INF/src webapps/twitterdemo/WEB-INF/views/signon
    webapps/twitterdemo/WEB-INF/views/accounts webapps/twitterdemo/WEB-INF/views/tweets

-------------------------------------
                code    total   files
-------------------------------------
java            173     280     7
jsp             121     138     8
-------------------------------------
summary         294     418     15

There are about 170 lines of Java code and about 120 lines of jsp code. Scooter framework does save us lots of coding. Please don't pay for a software by its total number of lines.

Other deployment options

Besides using embedded Jetty web server that comes with Scooter, you may also use Apache's Tomcat. You may deploy a war to other web servers too. For more details, please refer to Scooter's deployment document.