Typesafe Activator

Tweetmap Workshop Template

Tweetmap Workshop Template

retroryan
Source
October 17, 2014
basics playframework akka scala starter tweetmap

Starting Template for the Tweetmap Workshop

How to get "Tweetmap Workshop Template" on your computer

There are several ways to get this template.

Option 1: Choose tweetmap-workshop in the Typesafe Activator UI.

Already have Typesafe Activator (get it here)? Launch the UI then search for tweetmap-workshop in the list of templates.

Option 2: Download the tweetmap-workshop project as a zip archive

If you haven't installed Activator, you can get the code by downloading the template bundle for tweetmap-workshop.

  1. Download the Template Bundle for "Tweetmap Workshop Template"
  2. Extract the downloaded zip file to your system
  3. The bundle includes a small bootstrap script that can start Activator. To start Typesafe Activator's UI:

    In your File Explorer, navigate into the directory that the template was extracted to, right-click on the file named "activator.bat", then select "Open", and if prompted with a warning, click to continue:

    Or from a command line:

     C:\Users\typesafe\tweetmap-workshop> activator ui 
    This will start Typesafe Activator and open this template in your browser.

Option 3: Create a tweetmap-workshop project from the command line

If you have Typesafe Activator, use its command line mode to create a new project from this template. Type activator new PROJECTNAME tweetmap-workshop on the command line.

Option 4: View the template source

The creator of this template maintains it at https://github.com/retroryan/activator-tweetmap-workshop#master.

Option 5: Preview the tutorial below

We've included the text of this template's tutorial below, but it may work better if you view it inside Activator on your computer. Activator tutorials are often designed to be interactive.

Preview the tutorial

Welcome to the Tweetmap Going Reactive Tutorial! This tutorial starts with a basic Play Framework application and uses it to build a reactive tweet map.

Within the Activator UI you can:

  • Browse & edit the code (select Code. To save a file the keyboard shortcut command-s works.)
  • Add & delete files from the code (select Code and then the plus sign. To delete open the file and click on delete)
  • Open the code in IntelliJ IDEA or Eclipse (select Code, then the gear dropdown)
  • See the compile output (select Compile)
  • Test the application (select Test)
  • Run the application (select Run)

View the App

Click on the Run Tab and click on start to start the application running. Activator will automatically update the server when changes are made to the code.

Once the application has been compiled and the server started, your application can be accessed at: http://localhost:9000

Check in Run to see the server status.

Reactive Requests

Play Scala uses Futures to execute asynchronous tasks in the background. The Future is the handle to a future result. A callback function is added to the Future that gets called when the future completes.

The primary way of adding a callback to a Future is to add a map method that essentially means map the result of the Future to a new value - which in this case is a Response.

1. Create a new route that will search twitter by updatingconf/routes with the following route:

GET    /search      controllers.Tweets.search(query: String)

2. Update app/controllers/Tweets.scala to add a reactive request handler (or controller) for /tweets:



import scala.concurrent.Future
import play.api.libs.json.{JsValue, Json}
import play.api.libs.ws._
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.Play.current

/**
* A reactive request is made in Play by returning a Future[Result].  This makes the request asynchronous
* since the server doesn't block waiting for the response.  This frees the server thread to handle other requests.
* The callback to the Future is added as a map, which maps the results to a new value when the Future completes.
* The results of the Future in this example are mapped to a result (HTTP 200 OK) that gets returned to the client.
**/
def search(query: String) = Action.async {
    fetchTweets(query).map(tweets => Ok(tweets))
}

/**
* Fetch the latest tweets and return the Future[JsValue] of the results.
* This fetches the tweets asynchronously and fulfills the Future when the results are returned by calling the function.
* The results are first filtered and only returned if the result status was OK.
* Then the results are mapped (or transformed) to JSON.
**/
def fetchTweets(query: String): Future[JsValue] = {
    val tweetsFuture = WS.url("http://twitter-search-proxy.herokuapp.com/search/tweets").withQueryString("q" -> query).get()
    tweetsFuture
        .filter(response => response.status == play.api.http.Status.OK)
        .map { response =>
                response.json
            } recover {
                case _ => Json.obj("statuses" -> Json.arr(Json.obj("text" -> "Error retrieving tweets")))
            }
}

3. Test it: http://localhost:9000/search?query=typesafe

AngularJS UI

1. The build.sbt file already has dependencies on AngularJS and Bootstrap:

"org.webjars" % "bootstrap" % "3.1.1",
"org.webjars" % "angularjs" % "1.2.16",
        

2. AngularJS has already been enabled in the main twirl template


<html ng-app="tweetMapApp">
<script src="@routes.Assets.versioned("lib/angularjs/angular.min.js")"></script>

3. Add the following to index.js to fetch the tweets:



app.factory('Twitter', function($http, $timeout) {

    var twitterService = {
        tweets: [],
        query: function (query) {
            $http({method: 'GET', url: '/search', params: {query: query}}).
                success(function (data) {
                    twitterService.tweets = data.statuses;
                });
        }
    };

    return twitterService;
});

app.controller('Search', function($scope, $http, $timeout, Twitter) {

    $scope.search = function() {
        Twitter.query($scope.query);
    };

});

app.controller('Tweets', function($scope, $http, $timeout, Twitter) {

    $scope.tweets = [];

    $scope.$watch(
        function() {
            return Twitter.tweets;
        },
        function(tweets) {
            $scope.tweets = tweets;
        }
    );

});

4. Replace the contents of index.scala.html file with:


@(message: String)

@main(message) {

    <div ng-controller="Tweets">
        <ul>
            <li ng-repeat="tweet in tweets">{{tweet.text}}</li>
        </ul>
    </div>
}

5. Run the app, make a query, and verify the tweets show up: http://localhost:9000

WebSockets with Akka Actor

WebSockets provide a bi-directional, full-duplex communications channels over a single TCP connection.

Play provides different mechanisms for handling WebSockets. In this tutorial we are going to use an actor to handle the WebSockets. First create the Actor to handle the WebSockets. First create the actor that will handle the WebSocket communication.

1. Create a new UserActor.scala file in /app/actors containing:



package actors

import akka.actor.{ActorLogging, Actor, ActorRef, Props}
import play.api.libs.json.JsValue
import scala.concurrent.duration._
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import controllers.Tweets

/** The out actor is wired in by Play Framework when this Actor is created.
*   When a message is sent to out the Play Framework then sends it to the client WebSocket.
*
**/
class UserActor(out: ActorRef) extends Actor with ActorLogging {

    //The query is optional so that it starts as a None until the user issues the first query.
    var maybeQuery: Option[String] = None

    //Simulate events by periodically sending a message to self to fetch tweets.
    val tick = context.system.scheduler.schedule(Duration.Zero, 5.seconds, self, UserActor.FetchTweets)

    def receive = {
        //Handle the FetchTweets message to periodically fetch tweets if there is a query available.
        case UserActor.FetchTweets =>
            maybeQuery.map { query =>
                //sending a message to out sends it to the client websocket out by the Play Framework.
                Tweets.fetchTweets(query).map(tweetUpdate =>  out ! tweetUpdate)
            }

            case message: JsValue =>
                maybeQuery = (message \ "query").asOpt[String]
    }

    override def postStop() {
        tick.cancel()
    }

}

object UserActor {
    case object FetchTweets

    def props(out: ActorRef) = Props(new UserActor(out))
}

Wire up the WebSockets

WebSockets are created in Play using a normal route. The difference is the controller returns a WebSocket instead of a Result.

1. Add a route for the WebSockets connection to the routes file:

GET    /ws      controllers.Tweets.ws

2. Add a new controller method to create the Websocket in app/controllers/Tweets.scala:


    import play.api.mvc.WebSocket
    import actors.UserActor

    def ws = WebSocket.acceptWithActor[JsValue, JsValue] { request => out =>
        UserActor.props(out)
    }

3. Update the body of the app.factory section of index.js replacing the var twitterService = ... with :


    var ws = new WebSocket("ws://localhost:9000/ws");

    var twitterService = {
        tweets: [],
        query: function (query) {
            ws.send(JSON.stringify({query: query}));
        }
    };

    ws.onmessage = function(event) {
        $timeout(function() {
            twitterService.tweets = JSON.parse(event.data).statuses;
        });
    };

    return twitterService;

To verify tweets are showing up it is useful to use a browser inspector and then look under network for the path ws. Under there look at the frame and verify the requests are being sent. In chrome the network inspector has a bug and the websocket calls are not refreshed unless you tab out and back in.

4. Run the app and verify the tweets show up: http://localhost:9000

Update the Twitter Search to add Geo-Coding

1. Create new functions in app/controllers/Tweets.scala to to get (or fake) the location of the tweets:



//update the json imports to these imports:
import play.api.libs.json.{JsObject, JsValue, Json}
import play.api.libs.json.__

import scala.util.Random


private def putLatLonInTweet(latLon: JsValue) = __.json.update(__.read[JsObject].map(_ + ("coordinates" -> Json.obj("coordinates" -> latLon))))

private def tweetLatLon(tweets: Seq[JsValue]): Future[Seq[JsValue]] = {
    val tweetsWithLatLonFutures = tweets.map { tweet =>
        if ((tweet \ "coordinates" \ "coordinates").asOpt[Seq[Double]].isDefined) {
            Future.successful(tweet)
        } else {
            val latLonFuture: Future[(Double, Double)] = (tweet \ "user" \ "location").asOpt[String].map(lookupLatLon).getOrElse(Future.successful(randomLatLon))
            latLonFuture.map { latLon =>
                tweet.transform(putLatLonInTweet(Json.arr(latLon._2, latLon._1))).getOrElse(tweet)
            }
        }
    }

    Future.sequence(tweetsWithLatLonFutures)
}

private def randomLatLon: (Double, Double) = ((Random.nextDouble * 180) - 90, (Random.nextDouble * 360) - 180)

private def lookupLatLon(query: String): Future[(Double, Double)] = {
    val locationFuture = WS.url("http://maps.googleapis.com/maps/api/geocode/json").withQueryString(
        "sensor" -> "false",
        "address" -> query
    ).get()

    locationFuture.map { response =>
        (response.json \\ "location").headOption.map { location =>
                ((location \ "lat").as[Double], (location \ "lng").as[Double])
            }.getOrElse(randomLatLon)
    }
}

2. In app/controllers/Tweets.scala update the fetchTweets function to use the new tweetLatLon function:




def fetchTweets(query: String): Future[JsValue] = {
    val tweetsFuture = WS.url("http://twitter-search-proxy.herokuapp.com/search/tweets").withQueryString("q" -> query).get()
        tweetsFuture.flatMap { response =>
            tweetLatLon((response.json \ "statuses").as[Seq[JsValue]])
        } recover {
            case _ => Seq.empty[JsValue]
        } map { tweets =>
            Json.obj("statuses" -> tweets)
        }
}

    

Add the Tweet Map

1. The webjar dependency on leaflets has already been added to build.sbt

2. The Leaflet CSS and JS have already been added to main.scala.html file:


    <link rel='stylesheet' href='@routes.Assets.versioned("lib/leaflet/leaflet.css")'>
    <script type='text/javascript' src='@routes.Assets.versioned("lib/leaflet/leaflet.js")'></script>
    <script type='text/javascript' src='@routes.Assets.versioned("lib/angular-leaflet-directive/angular-leaflet-directive.min.js")'></script>

3. Above the <ul> in index.scala.html add a map:

<leaflet width="100%" height="500px" markers="markers"></leaflet>

4. Update the first line of index.js with:


    var app = angular.module('tweetMapApp', ["leaflet-directive"]);

5. Update the body of the app.controller('Tweets' ... section of the index.js file with the following:


    $scope.tweets = [];
    $scope.markers = [];

    $scope.$watch(
        function() {
            return Twitter.tweets;
        },
        function(tweets) {
            $scope.tweets = tweets;

            $scope.markers = tweets.map(function(tweet) {
                return {
                    lng: tweet.coordinates.coordinates[0],
                    lat: tweet.coordinates.coordinates[1],
                    message: tweet.text,
                    focus: true
                }
            });

        }
    );



6. Go to http://localhost:9000 to see the TweetMap!

Test the Controller

1. Update the test/ApplicationSpec.scala file with these tests:

import play.api.libs.json.JsValue
import play.api.test._
import play.api.test.Helpers._

"Application" should {

    "render index template" in new WithApplication {
        val html = views.html.index("Coco")
        contentAsString(html) must include("Coco")
    }

    "render the index page" in new WithApplication{
        val home = route(FakeRequest(GET, "/")).get

        status(home) must be(OK)
        contentType(home) must be(Some("text/html"))
        contentAsString(home) must include("Tweets")
    }

    "search for tweets" in new WithApplication {
        val search = controllers.Tweets.search("typesafe")(FakeRequest())

        status(search) must be(OK)
        contentType(search) must be(Some("application/json"))
        (contentAsJson(search) \ "statuses").as[Seq[JsValue]].length must be > 0
    }

}
 

2. Run the tests in Activator

comments powered by Disqus