AJAX Example
This is an example around making and testing AJAX calls.
Shared Code
First we create a definition of our AJAX endpoint. This is cross-compiled for both JVM and JS.
package japgolly.webapputil.examples.ajax
import io.circe.{Decoder, Encoder}
import japgolly.webapputil.ajax.AjaxProtocol
import japgolly.webapputil.circe.JsonCodec
import japgolly.webapputil.general.{Protocol, Url}
object AjaxExampleShared {
// This is an example AJAX endpoint definition
object AddInts {
val url = Url.Relative("/add-ints")
case class Request(m: Int, n: Int)
type Response = Long
// Here we define the protocol between client and server.
// Specifically, it's the URL, the request and response types, plus the codecs for
// serialisation & deserialisation.
val protocol: AjaxProtocol.Simple[JsonCodec, Request, Response] = {
implicit val decoderRequest: Decoder[Request] =
Decoder.forProduct2("m", "n")(Request.apply)
implicit val encoderRequest: Encoder[Request] =
Encoder.forProduct2("m", "n")(a => (a.m, a.n))
val requestProtocol: Protocol.Of[JsonCodec, Request] =
// this combines decoderRequest and encoderRequest above
Protocol(JsonCodec.summon[Request])
val responseProtocol: Protocol.Of[JsonCodec, Response] =
// uses the circe's default implicits for Long <=> Json
Protocol(JsonCodec.summon[Response])
// "Simple" here means that the responseProtocol is static
// (as opposed to being polymorphic / dependently-typed on the request)
AjaxProtocol.Simple(url, requestProtocol, responseProtocol)
}
// This is here just so that it's easily available from the example JS tests.
//
// In a real-project you'd share as much logic with the JS tests as possible,
// abstracting away things like DB access. To do things properly-properly, you'd
// also use sbt modules to ensure this is only shared with the test JS and not the
// main JS (so that it becomes impossible for another team-member to accidently use
// the logic directly from JS and avoid the AJAX call).
//
def logic(req: Request): Response =
req.m.toLong + req.n.toLong
}
}
Backend
package japgolly.webapputil.examples.ajax
import io.circe.{Decoder, Json}
object AjaxExampleJvm {
// This takes JSON and returns JSON.
//
// It's left as an exercise to the reader to integrate a JSON-to-JSON endpoint into
// your web server of choice.
//
def serveAddInts(requestJson: Json): Decoder.Result[Json] = {
import AjaxExampleShared.AddInts.{logic, protocol}
protocol.requestProtocol.codec.decode(requestJson).map { request =>
val response = logic(request)
val responseJson = protocol.responseProtocol(request).codec.encode(response)
responseJson
}
}
}
Frontend
package japgolly.webapputil.examples.ajax
import japgolly.scalajs.react._
import japgolly.scalajs.react.vdom.html_<^._
import japgolly.webapputil.ajax.AjaxClient
import japgolly.webapputil.circe.JsonAjaxClient
import japgolly.webapputil.general.{AsyncFunction, ErrorMsg}
object AjaxExampleJs {
import AjaxExampleShared.AddInts.{protocol, Request, Response}
// There are different ways to make AJAX calls depending on your app and needs.
// ===================================================================================
// This is a primative example that makes a single call attempt and nothing else
def primativeExample(): AsyncCallback[AjaxClient.Response[Response]] =
JsonAjaxClient.singleCall(protocol)(Request(3, 8))
// ===================================================================================
// This is a higher-level example.
// - has automatic retries built-in by default
// - successful results are wrapped in Either[ErrorMsg, _]
// - out-of-protocol errors (eg. client lost connectivity) are caught and converted
// into a Left[ErrorMsg]. (see AsyncFunction.throwableToErrorMsg for details)
val asyncFunction: AsyncFunction[Request, ErrorMsg, Response] =
JsonAjaxClient.asyncFunction(protocol)
def betterExample(): AsyncCallback[Either[ErrorMsg, Response]] =
asyncFunction(Request(3, 8))
// ===================================================================================
// Here is an example of making AJAX calls from a React component.
object ExampleComponent {
// Notice how we just ask for a AsyncFunction here, which is effectively a
// Request => AsyncCallback[Either[ErrorMsg, Response]]
//
// The component doesn't care about the details of how call is made, which makes it
// super easy to test. In fact, it needn't even be an AJAX call! It could be a
// websocket call, a webworker call, etc. Whoever uses the component gets to decide,
// no changes here are necessary.
//
final case class Props(addInts: AsyncFunction[Request, ErrorMsg, Response])
sealed trait State
object State {
case object Ready extends State
case object Pending extends State
final case class Finished(result: Either[ErrorMsg, Response]) extends State
}
final class Backend($: BackendScope[Props, State]) {
// Hard-coding the request for simplicity
private val req = Request(2, 2)
private val onSubmit =
for {
_ <- $.setStateAsync(State.Pending)
p <- $.props.asAsyncCallback
result <- p.addInts(req) // no need to catch errors here
_ <- $.setStateAsync(State.Finished(result))
} yield ()
private def submitButton =
<.button(
s"What really is ${req.m} + ${req.n}? Ask our proprietary Addition Service!",
^.onClick --> onSubmit)
def render(s: State): VdomNode =
s match {
case State.Ready => submitButton
case State.Pending => submitButton(^.disabled := true)
case State.Finished(Right(res)) => <.div(s"${req.m} + ${req.n} = $res! OMG!")
case State.Finished(Left(err)) => <.div(^.color.red, "Error: ", err.value)
}
}
val Component = ScalaComponent.builder[Props]
.initialState[State](State.Ready)
.renderBackend[Backend]
.build
}
// ===================================================================================
// Here is another example of making AJAX calls from a React component, this time in a
// way that makes lower-level testing a bit easier.
//
// Most of this is the same as above, except for where there are comments.
object ExampleComponent2 {
// Notice we request a JsonAjaxClient and not a AsyncFunction here.
// The JsonAjaxClient could be a real one that makes real calls, or an in-memory
// instance for lower-level testing.
final case class Props(ajaxClient: JsonAjaxClient)
sealed trait State
object State {
case object Ready extends State
case object Pending extends State
final case class Finished(result: Either[ErrorMsg, Response]) extends State
}
final class Backend($: BackendScope[Props, State]) {
private val req = Request(2, 2)
private val onSubmit =
for {
_ <- $.setStateAsync(State.Pending)
p <- $.props.asAsyncCallback
fn = p.ajaxClient.asyncFunction(protocol) // create a means to make the
// AJAX call
result <- fn(req) // no need to catch errors here
_ <- $.setStateAsync(State.Finished(result))
} yield ()
private def submitButton =
<.button(
s"What really is ${req.m} + ${req.n}? Ask our proprietary Addition Service!",
^.onClick --> onSubmit)
def render(s: State): VdomNode =
s match {
case State.Ready => submitButton
case State.Pending => submitButton(^.disabled := true)
case State.Finished(Right(res)) => <.div(s"${req.m} + ${req.n} = $res! Wow!")
case State.Finished(Left(err)) => <.div(^.color.red, "Error: ", err.value)
}
}
val Component = ScalaComponent.builder[Props]
.initialState[State](State.Ready)
.renderBackend[Backend]
.build
}
}
Testing the Frontend
package japgolly.webapputil.examples.ajax
import japgolly.microlibs.testutil.TestUtil._
import japgolly.scalajs.react.AsyncCallback
import japgolly.scalajs.react.test._
import japgolly.webapputil.ajax.AjaxClient
import japgolly.webapputil.circe.test.TestJsonAjaxClient
import japgolly.webapputil.general.{AsyncFunction, ErrorMsg}
import org.scalajs.dom.HTMLButtonElement
import utest._
object AjaxExampleJsTest extends TestSuite {
override def tests = Tests {
"component1" - {
"success" - testExampleComponent1Success()
"failure" - testExampleComponent1Failure()
}
"component2" - testExampleComponent2()
}
// ===================================================================================
private def testExampleComponent1Success() = {
import AjaxExampleJs.ExampleComponent._
// Here we provide our own AsyncFunction that provides an answer in-memory
// immediately
val props = Props(AsyncFunction { req =>
val result = AjaxExampleShared.AddInts.logic(req)
AsyncCallback.pure(Right(result))
})
// Mount the component for testing...
ReactTestUtils.withRenderedIntoBody(Component(props)) { m =>
def dom() = m.getDOMNode.asMounted().asHtml()
// Test initial state
assertContains(dom().innerHTML, "What really is 2 + 2?")
// Test clicking through to the immediate test result
Simulate.click(dom())
assertContains(dom().innerHTML, "2 + 2 = 4")
}
}
// ===================================================================================
private def testExampleComponent1Failure() = {
import AjaxExampleJs.ExampleComponent._
// Let's simulate a connectivity error
val error = ErrorMsg("No internet connectivity")
val props = Props(AsyncFunction.const(Left(error)))
// Mount the component for testing...
ReactTestUtils.withRenderedIntoBody(Component(props)) { m =>
def dom() = m.getDOMNode.asMounted().asHtml()
// Click button, and confirm the component gracefully handles the error
Simulate.click(dom())
assertContains(dom().innerHTML, "Error: No internet connectivity")
}
}
// ===================================================================================
private def testExampleComponent2() = {
import AjaxExampleJs.ExampleComponent2._
// Here we create our own in-memory client for lower-level ajax testing.
//
// We're setting autoRespondInitially to false so that when it receives a request,
// it does nothing and waits for us to command it to respond. This will help us test
// the state of the component when a request is in-flight.
//
val ajax = TestJsonAjaxClient(autoRespondInitially = false)
// Here we teach our ajax client how to respond to calls to /add-ints
ajax.addAutoResponse(AjaxExampleShared.AddInts.protocol) { testReq =>
val result = AjaxExampleShared.AddInts.logic(testReq.input)
val response = AjaxClient.Response.success(result)
testReq.onResponse(Right(response))
}
// Mount the component for testing...
ReactTestUtils.withRenderedIntoBody(Component(Props(ajax))) { m =>
def dom() = m.getDOMNode.asMounted().asHtml()
// Test initial state
assertContains(dom().innerHTML, "What really is 2 + 2?")
// Click button, and test that the button is disabled, pending the ajax result
Simulate.click(dom())
assert(dom().asInstanceOf[HTMLButtonElement].disabled)
// Release the ajax response, and test the component displays it
ajax.autoRespondToLast()
assertContains(dom().innerHTML, "2 + 2 = 4")
}
}
}