Using WebSockets in Ratpack

The Ratpack JVM framework has great builtin support for realtime communication between clients via WebSockets. In this post we’ll go through an example of building and testing a small app where users can submit their daily Scrum standup report. The app uses a Ratpack REST API and WebSocket backend with a Vue.js frontend.

The full code for this application is available on GitHub: https://github.com/craigatk/ratpack-standup

Thanks

Big thanks to Dan Woods for his excellent Ratpack/WebSocket example from Gr8Conf US 2014 that I used as a basis for using WebSockets with Ratpack.

Ratpack endpoint

First, let’s create an endpoint in the Ratpack handler chain for a WebSocket client to connect to. When a client connects to the /ws/status endpoint, we’ll register the client to receive updates whenever a status object is created via the REST API.

/* src/ratpack/ratpack.groovy */

ratpack {
    bindings {
        bindInstance(new StatusBroadcaster())
        ...
    }
    handlers {
        get("ws/status") { StatusBroadcaster broadcaster ->
            broadcaster.register(context)
        }
        ...
    }
}

Ratpack WebSocket broadcaster

We’ll delegate the registration of clients and broadcasting of messages to a separate StatusBroadcaster class. When a new WebSocket client connects, we’ll add the client to a listener list. And then when a new status object is created, we’ll broadcast that new object to each of the clients in our listener list. Finally, when a WebSocket closes we’ll remove that client from the listener list.

/* src/main/groovy/standup/StatusBroadcaster.groovy */

package standup

import ratpack.handling.Context
import ratpack.websocket.WebSocket
import ratpack.websocket.WebSockets

import java.util.concurrent.CopyOnWriteArrayList

import static groovy.json.JsonOutput.toJson

class StatusBroadcaster {
  private final List<WebSocket> listeners = new CopyOnWriteArrayList<>()

  public void register(Context context) {
    WebSockets.websocket(context) { ws ->

      listeners << ws

      return ws
    } connect { ws ->
      ws.onClose {
        listeners.remove(it.openResult)
      }
    }
  }

  public void sendMessage(Status status) {
    listeners.each { WebSocket ws ->
      ws.send(toJson(status))
    }
  }
}

Javascript client code

Then on the Javascript side in the Vue.js app we can connect to the /ws/standup endpoint in the Ratpack app. And when a new WebSocket message comes in we’ll add it to the status list in the UI so all users stay in sync with the latest data.

/* src/app/src/websocket/index.js */

import store from '../store'

let webSocket

let initWebsocket = () => {
  if (!webSocket || webSocket.readyState !== WebSocket.OPEN) {
    // During development the Ratpack server is on port 5050
    let severPort = location.port === '8080' ? '5050' : location.port

    webSocket = new WebSocket('ws://' + location.hostname + ':' + severPort + '/ws/status')

    webSocket.onmessage = (event) => {
      // Parse the status object sent from the server and
      // add it to the status list displayed in the UI
      let newStatus = JSON.parse(event.data)
      store.addStatus(newStatus)
    }
  }
}

export default initWebsocket

Test with WebSocket client

We can write a that uses a WebSocket client to verify the application sends updates via WebSocket when a new status is created via the REST API. The code for the test WebSocket client is also on Github.

/* src/test/groovy/standup/websocket/StatusPublisherSpec.groovy */

package standup.websocket

import groovy.json.JsonSlurper
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import spock.lang.AutoCleanup
import spock.lang.Specification
import standup.Status

import java.util.concurrent.TimeUnit

import static groovy.json.JsonOutput.toJson

class StatusPublisherSpec extends Specification {
    @AutoCleanup
    def aut = new GroovyRatpackMainApplicationUnderTest()

    JsonSlurper jsonSlurper = new JsonSlurper()

    void 'should broadcast new status to WebSocket client'() {
        given:
        Status newStatus = new Status(name: 'WebSocket Test', yesterday: 'Done yesterday', today: 'Working on today')

        TestWebSocketClient wsClient = openWsClient()
        wsClient.connectBlocking()

        when:
        aut.httpClient.requestSpec { spec ->
            spec.body { b ->
                b.text(toJson(newStatus))
            }
        }.post("api/status")

        then:
        def rawJson = wsClient.received.poll(2, TimeUnit.SECONDS)
        assert rawJson

        def parsedJson = jsonSlurper.parseText(rawJson)

        assert parsedJson.name == 'WebSocket Test'
        assert parsedJson.yesterday == 'Done yesterday'
        assert parsedJson.today == 'Working on today'

        cleanup:
        wsClient.closeBlocking()
    }

    private TestWebSocketClient openWsClient() {
        new TestWebSocketClient(new URI("ws://localhost:${aut.address.port}/ws/status"))
    }
}

Test with browser

We can also use a Geb browser functional test to verify the WebSocket connection between client and server. In the test, we’ll send in a POST request to create a new status object via the API (instead of using the browser) and verify the browser updates in realtime without needing a refresh.

/* src/test/groovy/standup/geb/StandupGebSpec.groovy */

package standup.geb

import geb.spock.GebReportingSpec
import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import spock.lang.AutoCleanup

import static groovy.json.JsonOutput.toJson

class StandupGebSpec extends GebReportingSpec {
    @AutoCleanup
    def aut = new GroovyRatpackMainApplicationUnderTest()

    def setup() {
        URI base = aut.address
        browser.baseUrl = base.toString()
    }

    void "when new status added should update browser via WebSocket"() {
        given:
        HomePage homePage = to HomePage

        when: 'adding a new status using the JSON REST API'
        aut.httpClient.requestSpec { spec ->
            spec.body { b ->
                b.text(toJson([name: 'WebSocket Test']))
            }
        }.post("api/status")

        then:
        waitFor { homePage.hasStatusFor('WebSocket Test') }
    }
}

Test with multiple browsers

WebSockets are designed for communication across users - so for an even more realistic test, what if we could verify communication across browsers in a Geb functional test? I found an example of using multiple browsers in a single Geb test and adapted it to this use case. The test will open up two browsers and verify that status entries submitted in one browser are send to the other browser.

/* src/test/groovy/standup/geb/multi/StandupMultiBrowserGebSpec.groovy */

package standup.geb.multi

import ratpack.groovy.test.GroovyRatpackMainApplicationUnderTest
import spock.lang.AutoCleanup
import standup.geb.HomePage

class StandupMultiBrowserGebSpec extends MultiBrowserGebSpec {
    @AutoCleanup
    def aut = new GroovyRatpackMainApplicationUnderTest()

    def setup() {
        URI base = aut.address
        browser.baseUrl = base.toString()
    }

    @Override
    String getBaseUrl() {
        aut.address.toString()
    }

    void 'when submitted status in a separate browser window should appear in both browsers'() {
        given:
        HomePage homePageFirstBrowser = to(HomePage)

        when:
        withBrowserSession('second') {
            HomePage homePageSecondBrowser = to(HomePage)
            homePageSecondBrowser.submitStatus('Second browser')
        }

        then:
        waitFor { homePageFirstBrowser.findStatusFor('Second browser') }

        when:
        homePageFirstBrowser.submitStatus('First browser')

        then:
        withBrowserSession('second') {
            HomePage homePageSecondBrowser = browser.page
            waitFor { homePageSecondBrowser.findStatusFor('First browser') }
        }
    }
}

I plan to write a future post to go into more depth about using multiple browsers in a single Geb test.

Wrapup

Ratpack provides powerful builtin support for WebSockets that makes it easy to offer realtime communication in your applications. And the ease of testing Ratpack applications gives us a variety of options for ensuring our WebSocket applications work correctly.

And again the full code for this application is available on GitHub. Thanks for reading and I hope this was helpful!