Tutorial: Shared json model between scalajs and scala backend

June 2018

In this tutorial we guide you through the process of setting up a web application with:

  • ScalaJS on the front-end
  • Scala (akka-http) on the back-end
  • Shared code between the front and back-end (isomorphism)

You'll build it from the ground-up and we keep things as simple as possible. You will need a web browser (duh) and sbt installed (https://www.scala-sbt.org/), any version higher than 0.13.16 will do (we are using 1.1.6)

Create the project

Download the directory structure and scalajs-crossproject-tutorial.tar.gz

Extract it somewhere on your computer and notice the following directory structure:


scala-series/
├── index.html
├── project
│   ├── build.properties
│   └── plugins.sbt
├── front-end
│   └── src
│       └── main
│           └── scala
├── back-end
│   └── src
│       └── main
│           └── scala
└── shared
    └── src
        └── main
            └── scala

Only the following files have contents:

  • index.html a empty html page
  • project/build.properties the version number of sbt (1.1.6)

Front-end

We start by quickly getting scalajs up and running.

First add the scalajs plugin to your project/plugins.sbt:

addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.23")

Let's define our project inside our build file:

lazy val frontEnd = (project in file("front-end"))
        .enablePlugins(ScalaJSPlugin)
        .settings(
            scalaVersion := "2.12.6",
            scalaJSUseMainModuleInitializer := true,
            libraryDependencies ++= Seq(
                "org.scala-js" %%% "scalajs-dom" % "0.9.2",
                "com.typesafe.play" %%% "play-json" % "2.6.0",
            )
        ) 

For those less familiar with scalajs:

  • We are enabling the scalajs plugin you added to the plugins of this project
  • we are using scalaJSUseMainModuleInitializer as we want to build a front-end application as apposed to a scalajs/javascript library
  • We are adding scalajs-dom so that we can used the DOM api using types
  • Later on we will be using play-json, we are just already defining it like we did for the back-end

Cool, now let's define a application 'demo.fe.SampleFrontendApp'

import org.scalajs.dom
import org.scalajs.dom.Event

object SampleFrontendApp {
    def main(args: Array[String]): Unit = {
        dom.window.addEventListener("load",initApplication)
    }
    
    def initApplication(e: Event) = {
        println("initialising application…")
        displayTitle("The Americans")
        displayTitle("Vinyl")
    }
    
    def displayTitle(title: String) =
            dom.document.getElementById("titles")
              .insertAdjacentHTML("beforeend",s"<li>$title</li>")
}

So we are registering an onload event handler in which we log to the console and we display two series titles using displayTitle. This helper function adds an li to a ul element that we will add to our HTML page later. Again we are just making something visible so we can quickly get everything working so that we add "complexity" to the application.

Let's compile the scalajs application using sbt (and keep watching the files for recompilation)

$ sbt ~frontEnd/fastOptJS

This should produce a javascript file named front-end/target/scala-2.12/frontend-fastopt.js. Brilliant let's then now change the page so that we can test our scalajs code

The Page

Open the index.html . You should already find a html5 template (created with emmet).

First we add some content to the page including our ul element with id titles:

<body>
    <section>
        <h1>Series</h1>
    
        <ul id="titles">
    
        </ul>
    </section>
    <footer>(fastopt version)</footer>
</body>

Now include our compiled javascript to the page (inside the head element)

<head><script src="front-end/target/scala-2.12/frontend-fastopt.js"></script>
</head>

You should be able to open your html page inside a browser and notice the titles are shown. Also notice the log message in your browser's development console.

That works, so let's now work on our backend

The backend

For the back-end we will be using

  • akka-actor, akka-http and akka-slf4j
  • akka-http-circe and akka-http-play-json later on for the json marshalling

Let's define the project inside our 'build.sbt':

lazy val backEnd = (project in file("back-end"))
        .settings(
            resolvers += Resolver.bintrayRepo("hseeberger", "maven"),
            libraryDependencies ++= Seq(
                "com.typesafe.akka" %% "akka-actor" % "2.5.11",
                "com.typesafe.akka" %% "akka-http" % "10.1.0",
                "de.heikoseeberger" %% "akka-http-circe" % "1.20.0",
                "de.heikoseeberger" %% "akka-http-play-json" % "1.20.0",
                "com.typesafe.akka" %% "akka-slf4j" % "2.5.11",
            )
        ) 

We will place the actual akka-http route inside a trait named demo.be.SeriesService. Create this trait and define a route:

trait SeriesService  {
    lazy val seriesApi =
        path("api" / "series") {
            respondWithHeaders(`Access-Control-Allow-Origin`.*) {
                get {
                    complete("McMafia")
                }
            }
        }
}

We will change the route later, but we are just trying to get things rolling as quick as possible. For those who are not familiar with akka-http:

  • We are using the akka-http DSL to define a route
  • When a request comes in rules (in the form of directives) determine what will be returned
  • In our case we have a match for a URI path /api/series
  • Any http request send to that uri will have a CORS header allowing any host to use our endpoint
  • When the request method is a http GET, we reply with the text "hello friend"

A quick note on the CORS header allowing any host: You would normally never do that, but in our case we will test our application by just opening a html file from the filesystem. Normally you would allow just the host/port used to serve your web resources.

We can now define our application. Create a new object named demo.be.SampleServiceApp and make it a Scala application by extending App plus mixin our SeriesService trait

object SampleServiceApp extends App with SeriesService {

}

We then define the necessary implicits and start our server with our route rules:

implicit val system = ActorSystem("my-system")
implicit val materializer = ActorMaterializer()
implicit lazy val ex: ExecutionContext = system.dispatcher

Http().bindAndHandle(seriesApi, "0.0.0.0", 3000) onComplete {
    case Success(b) => println(s"server is running ${b.localAddress} ")
    case Failure(e) => println(s"there was an error starting the server $e")
}

This will open port 3000 on all local IP addresses and use our seriesApi route to service requests.

Run your application using sbt

$ sbt backEnd/run

Quickly test your endpoint by sending a GET request to http://localhost:3000/api/series either in your browser or using curl

$  curl http://localhost:3000/api/series
McMafia

Use our endpoint

So time to update our front-end code and make a XHR request to our server.

Add a method getData to make the XHR request using scalajs-dom's extension Ajax.get

def getData =
        Ajax.get("http://localhost:3000/api/series")
                .map(_.responseText)

We can then use this in our initApplication method

import scala.concurrent.ExecutionContext.Implicits.global

getData.onComplete{
    case Success(v)=>displayTitle(v)
    case Failure(e)=>println(s"oops: $e")
}

Upon successful completion we display the title and in the event of a failure we write to the console.

You are most likely still running the triggered execution (sbt ~frontEnd/fastOptJS) so you should just have to refresh your browser and you should see the series your server is sending.

Top! time to add some json and a shared scala model representing a series.

The Cross Platform Project

This is where it will become really cool. We are going to define a cross project using sbt-crossproject for our model classes.

The sbt-crossproject project allows "Cross-Compiling Scala.js, JVM and Native".

Add the required plugin to your projects/plugins (in this case we need cross-compiling for JS and the JVM)

addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "0.4.0")

We can now define a JS/JVM cross project for our shared model:

lazy val shared = crossProject(JSPlatform, JVMPlatform)
        .crossType(CrossType.Pure)
        .settings(
            libraryDependencies ++= Seq(
                "com.typesafe.play" %% "play-json" % "2.6.0",
                )
        )

The crossType needs explanation. This setting determines the source tree layout of the cross project. We are using Pure which means there will be no directories for platform-specific code. All the code will be placed in the src folder and is considered "shared" between scalajs and the jvm compilation. Another setting would be Pure which creates source directories such as js, jvm and shared. We have no platform-specific code, so only need a single src folder.

Next we can define platform specific sbt Projects and add those as dependencies to our front- and back-end projects:

lazy val sharedJVM = shared.jvm
lazy val sharedJS = shared.js

lazy val frontEnd = project
                      .dependsOn(sharedJS)
                      …

lazy val backEnd = project
                      .dependsOn(sharedJVM)
                      …

Pretty cool, no? Let's define our model classes next.

Json Model classes

Within the shared project add a demo.shared.Series case class

case class Series(id: String, title: String)

We will now define json support for our model. We have already added the dependencies to our projects. As you might have noticed we will use Play's Json support. We have also added akka-http-json and akka-http-play-json so that we can use play-json with akka-http. The reason we are using play-json and not akka's own json support is to use one json configuration for both the front and back-end (akka json does not support scalajs)

This means we can define our marshaller inside the shared project! Awesome (it is really).

Again inside the shared project add a trait named demo.shared.JsonSupport and define the Format implicit for our Series case class:

import play.api.libs.json.Json

trait JsonSupport  {
    implicit val SeriesFormat = Json.format[Series]
}

Next we need to change the back- and front-ends.

Change the Back-end

We start with the back-end.

Open the demo.be.SeriesService and mixin our JsonSupport plus the PlayJsonSupport so that akka-http can use Play's json support:

import demo.shared.JsonSupport
import de.heikoseeberger.akkahttpplayjson.PlayJsonSupport
import demo.shared.Series

trait SeriesService extends JsonSupport with PlayJsonSupport {
    …
}

Then just change the response value to return a sequence of Series (which will be marshaled into a json array)

get {
    complete(Seq(Series("8219","Sense8"), Series("9214","Better Call Saul")))
}

If you are still running the previous server, make sure you stop it and recompile/run your new version

$ sbt backEnd/run

Quickly test your updated endpoint either in your browser or using curl

$ curl http://localhost:3000/api/series
[ {
  "id" : "8219",
  "title" : "Sense8"
}, {
  "id" : "9214",
  "title" : "Better Call Saul"
} ]

Time to update the front-end

Update the front-end

Make sure you restart the triggered execution as we have updated the build file

$ sbt ~frontEnd/fastOptJS

Then open your demo.fe.SampleFrontendApp and mixin our json support class:

import demo.shared.{JsonSupport, Series}

object SampleFrontendApp extends JsonSupport {

}

Then change the getData method to parse (Json.parse) and unmarshal (as[Seq[Series]]) the response from our service call:

def getData : Future[Seq[Series]] =
        Ajax.get("http://localhost:3000/api/series")
                .map(_.responseText)
            .map(Json.parse(_))
            .map(_.as[Seq[Series]])

As a final step change the initApplication and call displayTitle for each series returned

getData.onComplete{
    case Success(v)=>v.foreach(s=>displayTitle(s.title))
    case Failure(e)=>println(s"oops: $e")
}

Refresh your page and "Wubba Lubba dub-dub, it works!" (well in excitement and not because you are in pain)

You can find the solution of this tutorial on github