Exporting Validations to Javascript using Scala.js
Validation 2.0.x supports Scala.js, which allows compiling validation logic for JavaScript to run it directly in the browser. Let's begin by playing with it. Try to change the tryMe
variable in the following editor. The result is automatically outputted.
Using validation from Scala.js is no different than any other Scala library. There is, however, some friction to integrate Scala.js into an existing Play + JavaScript, which we try to address in this document. Assuming no prior knowledge on Scala.js, we explain how to cross-compile and integrate validation logic into an existing Play/JavaScript application.
You will first need to add two SBT plugins, Scala.js itself and sbt-play-scalajs
to make it Scala.js and Play coexist nicely:
scala> cat("project/plugins.sbt")
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.5.2")
addSbtPlugin("org.scala-js" % "sbt-scalajs" % "0.6.9")
addSbtPlugin("com.vmunier" % "sbt-play-scalajs" % "0.3.0")
Scala.js uses a separate compilation pass to transform Scala sources to a single .js
file. Specifying which part of a Scala codebase should be processed by Scala.js is done by splitting the code in different SBT projects. This is usually done with 3 projects, one targeting the JVM, another one targeting JS, and a third one for code shared between the two. In case of a Play application it could look like the following:
<project root>
+- build.sbt
+- jvm
| +- app
| +- conf
| +- public
| +- test
+- js
| +- src/main/scala
+- shared
+- src/main/scala
Now let's look at a minimal build.sbt
reflecting this structure. Information on the sbt settings are available on the Scala.js documentation on cross build, and on sbt-play-scalajs
documentation.
scala> cat("build.sbt")
val scalaV = "2.11.8"
val validationVersion = "2.0"
lazy val jvm = project
.in(file("jvm"))
.settings(
scalaVersion := scalaV,
scalaJSProjects := Seq(js),
pipelineStages := Seq(scalaJSProd),
libraryDependencies ++= Seq(
"com.vmunier" %% "play-scalajs-scripts" % "0.5.0",
"io.github.jto" %% "validation-core" % validationVersion,
"io.github.jto" %% "validation-playjson" % validationVersion,
"io.github.jto" %% "validation-jsonast" % validationVersion))
.enablePlugins(PlayScala)
.aggregate(js)
.dependsOn(sharedJVM)
lazy val js = project
.in(file("js"))
.settings(
scalaVersion := scalaV,
persistLauncher := true,
libraryDependencies ++= Seq(
"io.github.jto" %%% "validation-core" % validationVersion,
"io.github.jto" %%% "validation-jsjson" % validationVersion,
"io.github.jto" %%% "validation-jsonast" % validationVersion))
.enablePlugins(ScalaJSPlugin, ScalaJSPlay)
.dependsOn(sharedJS)
lazy val sharedJVM = shared.jvm
lazy val sharedJS = shared.js
lazy val shared = crossProject.crossType(CrossType.Pure)
.in(file("shared"))
.settings(
scalaVersion := scalaV,
libraryDependencies ++= Seq(
"io.github.jto" %%% "validation-core" % validationVersion,
"io.github.jto" %%% "validation-jsonast" % validationVersion))
.jsConfigure(_.enablePlugins(ScalaJSPlay))
onLoad in Global := (Command.process("project jvm", _: State)) compose (onLoad in Global).value
In addition to the validation
dependency, we also included play-scalajs-scripts
, which provides a convenient way to link the output of Scala.js compilation from a Play template:
scala> cat("jvm/app/views/main.scala.html")
@(title: String)(content: Html)(implicit environment: play.api.Environment)
<!DOCTYPE html>
<html>
<head>
<title>@title</title>
</head>
<body>
@content
@* Outputs a <script></script> tag to include the output of Scala.js compilation. *@
@playscalajs.html.scripts(projectName = "js")
</body>
</html>
Let's define a simple case class for our example inside of the shared
project to make it available to both JVM and JV platforms. We collocate a simple validation for this case class in its companion object:
scala> cat("shared/src/main/scala/User.scala")
package model
import jto.validation._
import jto.validation.jsonast._
import scala.Function.unlift
case class User(
name: String,
age: Int,
email: Option[String],
isAlive: Boolean
)
object User {
import Rules._, Writes._
implicit val format: Format[JValue, JObject, User] =
Formatting[JValue, JObject] { __ =>
(
(__ \ "name").format(notEmpty) ~
(__ \ "age").format(min(0) |+| max(130)) ~
(__ \ "email").format(optionR(email), optionW(stringW)) ~
(__ \ "isAlive").format[Boolean]
)(User.apply, unlift(User.unapply))
}
}
Note the use of jto.validation.jsonast
here. This project implements in just a few lines of code an immutable version of the JSON specification based on Scala collections: (It might eventually be replaced with an external abstract syntax tree (AST), see discussion in https://github.com/scala/slip/pull/28)
scala> cat("../validation-jsonast/shared/src/main/scala/JValue.scala")
package jto.validation
package jsonast
sealed trait JValue
case object JNull extends JValue
case class JObject (value: Map[String, JValue] = Map.empty) extends JValue
case class JArray (value: Seq[JValue] = Seq.empty) extends JValue
case class JBoolean(value: Boolean) extends JValue
case class JString (value: String) extends JValue
case class JNumber (value: String) extends JValue {
require(JNumber.regex.matcher(value).matches)
}
object JNumber {
val regex = """-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?""".r.pattern
def apply(i: Int): JNumber = JNumber(i.toString)
def apply(l: Long): JNumber = JNumber(l.toString)
def apply(d: Double): JNumber = JNumber(d.toString)
}
This AST has the same capabilities than other JSON representations, but it does no provide a parser nor a pretty printer. The suggested approach here is to use conversions from this cross compiled AST to platform specific ones to take advantage of existing platform specific serialization. To do so, Validation provides the following Rule
s and Write
s, defined in jto.validation.jsonast
:
Ast.from: Rule[play.api.libs.json.JsValue, JValue]
Ast.to: Write[JValue, play.api.libs.json.JsValue]
Ast.from: Rule[scala.scalajs.jsDynamic, JValue]
Ast.to: Write[JValue, scala.scalajs.jsDynamic]
To use our previously defined validation, we could compose what we defined targeting the cross compiling JSON AST with the above Rule
s / Write
s to finally obtain platform-specific validation.
One last technicality about Scala.js is the @JSExport
annotation, which is used to explicitly expose Scala objects and methods to the javascript world. To complete our example, we define and expose a single method taking a JSON representation of our case class and returning the output of our validation, also a JSON:
scala> cat("js/src/main/scala/Validate.scala")
package client
import jto.validation._
import jto.validation.jsonast.Ast
import jto.validation.jsjson._
import scala.scalajs.js
import js.annotation.JSExport
import model.User
import scala.Function.{unlift, const}
@JSExport
object Validate {
@JSExport
def user(json: js.Dynamic): js.Dynamic = {
import Writes._
implicit val format: Format[js.Dynamic, js.Dynamic, User] = Format(
Ast.from andThen User.format,
Write.toWrite(User.format) andThen Ast.to
)
To[VA[User], js.Dynamic](format.validate(json))
}
}
Finally, we can create a simple view with a textarea which validates it's content on every keystroke:
scala> cat("jvm/app/views/index.scala.html")
@(json: String)(implicit environment: play.api.Environment)
@main("Play Scala.js Validation") {
<textarea id="json-form" rows="10" cols="40">@json</textarea>
<pre id="validation-output"></pre>
}
<script type="text/javascript">
var validationOutputPre = document.getElementById("validation-output")
var jsonFormTextarea = document.getElementById("json-form")
var demo = function() {
try {
var json = JSON.parse(jsonFormTextarea.value);
validationOutputPre.innerHTML =
JSON.stringify(client.Validate().user(json), null, 2);
} catch(err) {
validationOutputPre.innerHTML = err.message;
}
};
jsonFormTextarea.addEventListener('input', demo, false);
demo();
</script>
This complete code of this example is available in the play-scalajs-example subproject. The binary used to power the editor at the beginning of this page was generated by running Play in production mode, which fully optimizes the output of Scala.js compilation using the Google Closure Compiler to obtain a final .js file under 100KB once gzipped.