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")
    }
  }
}