I know this is a very strange topic to be writing about. Why would anyone want to create a WebSocket server on Android and then have it implement the GraphQL-WS protocol? If you are curious about the why or want to know how to implement this, read on.
Why a GraphQL Server on Android
We live in an IoT world where everything is connected. Your laptop is connected to your phone, your phone is connected to your watch, your watch is connected to your bulb, and so on. I know it’s very generic, but we had a very good reason to set up a WS server on an Android app. Think admin devices that contain some info that needs to be passed to other devices/low-level systems in the same network.
Web Socket Server on Android
Now that the why is out of the way, let’s see how we can first set up a web socket server on Android. The org.java_websocket.server.WebSocketServer
makes it pretty straightforward. All we need is to extend that class passing the address that we want to listen to to the superclass and implement the WebSocketListener
interface.
If you want to automatically assign IP addresses from the WIFI interface card, here is a sample Dagger Module method that we use:
@JvmStatic
@Provides
@ActivityScoped
fun providesWebsocketServer(context: Context, queryHandler: WebSocketQueryHandler): WebSocketServer {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
val ipAddress = wm.connectionInfo.ipAddress
@Suppress("MagicNumber")
val ip = String.format(
"%d.%d.%d.%d",
ipAddress and 0xff, ipAddress shr 8 and 0xff, ipAddress shr 16 and 0xff, ipAddress shr 24 and 0xff
)
val port = Config.GRAPHQL_DEFAULT_PORT
val server = WebSocketServer(InetSocketAddress(ip, port), queryHandler)
server.isReuseAddr = true
return server
}
GraphQL WS Protocol
But, if you want to use this server with the Apollo GraphQL Client, you need to have a way to signal to the client that we support a protocol that it expects for communication over GraphQL and then support the methods of that protocol. Here is the specifications of the graphql-ws
protocol that Apollo clients expect us to support.
Protocol Headers
To support this, the first thing we need is to indicate through our server that we intend to support this protocol. For this, we will declare a class implementing the IProtocol
interface like this:
class GraphQLWSProtocol : IProtocol {
override fun getProvidedProtocol(): String = "graphql-ws"
override fun copyInstance(): IProtocol = GraphQLWSProtocol()
override fun acceptProvidedProtocol(inputProtocolHeader: String?): Boolean = inputProtocolHeader == "graphql-ws"
}
Now our WebSocketServer
class should specify to the parent that this protocol should be supported. Here is the class definition (I am not putting any of the implemented methods for conciseness):
class WebSocketServer(address: InetSocketAddress, val listener: WebSocketListener) :
org.java_websocket.server.WebSocketServer(
address,
listOf(Draft_6455(emptyList<IExtension>(), listOf<IProtocol>(GraphQLWSProtocol())))
)
Protocol Messages
In addition to the protocol headers, we need to follow the protocol’s suggested way of message exchange and acknowledgments. If you checked the Dagger module in detail, you will notice that we had a WebSocketQueryHandler
parameter as the server’s listener. This is where we will support the message passing paradigm from the protocol. For a very basic server, there are 4 operations that we need to support:
connection_init
: Initialize the connection and ACK the request.start
: Start a Query (with optional subscriptions)stop
: Stop a subscription that was started throughstart
.connection_terminate
: Terminate the connection. If we need to exchange more messages, we will create a new connection and go throughconnection_init
again.
Each message sent/received by the Apollo Client has the following fields:
@JsonClass(generateAdapter = true)
class Message(
@Json(name = "id") val id: String? = null,
@Json(name = "type") val type: MessageType,
@Json(name = "payload") val payload: Payload? = null
)
@JsonClass(generateAdapter = true)
class Payload(
@Json(name = "data") val data: Map<String, Any>? = null,
@Json(name = "errors") val errors: Array<Error>? = null,
@Json(name = "query") val query: String? = null,
@Json(name = "variables") val variables: Map<String, Any>? = null,
@Json(name = "operationName") val operationName: String? = null
)
@JsonClass(generateAdapter = true)
class Error(
@Json(name = "message") val message: String
)
Note that here we are using Moshi
’s annotations to perform JSON (de)serialization. You could use any other JSON coding library.
Inside our WebSocketQueryHandler
, we can implement our onMessage
callback simply like this:
override fun onMessage(conn: WebSocket?, message: String?) {
conn ?: return
message ?: return
val messageJson = adapter.fromJson(message) ?: return
when (messageJson.type) {
MessageType.INIT -> ack(conn)
MessageType.START -> startQuery(conn, messageJson)
MessageType.STOP -> stopQuery(conn, messageJson)
MessageType.TERMINATE -> close(conn)
else -> sendError(conn, "Message not recognized")
}
}
Closing the connection
private fun close(conn: WebSocket) {
conn.close()
}
ACK
private fun ack(conn: WebSocket) {
Timber.d("Ack connection to %s", conn.remoteSocketAddress.address.toString())
conn.send(adapter.toJson(Message(type = MessageType.ACK)))
conn.send(adapter.toJson(Message(type = MessageType.KEEP_ALIVE)))
}
Start Query
This is the most involved part of our implementation. We would need to parse the query, execute it and then if it is a subscription, persist the subscription info in our app to support canceling it in the future. Since this is something that would be very dependent on the App architecture, I am not going into the details of each of the involved parts. But, for completeness, I will mention that we are using the Publisher implementation to handle long-lasting cancellable subscriptions. And we are id
‘ing these subscriptions by concatenating the remote socket address and the message-id (sent from the Apollo client).
private fun startQuery(conn: WebSocket, message: Message) {
val payload = message.payload ?: return sendError(conn, "Failed to start query. Payload Empty", message.id)
val executionResult = graphQL.execute(buildQuery(payload))
if (executionResult.errors.isNotEmpty()) {
return sendExecutionError(conn, message, executionResult)
}
val data: Any? = executionResult.getData()
when {
data is Publisher<*> -> handleSubscriptionResult(conn, data, message.id)
data != null && data is Map<*, *> -> handleQueryResult(conn, data, message.id)
else -> sendError(conn, "Cannot execute query. Null result", id = message.id)
}
}
@Suppress("UNCHECKED_CAST")
private fun handleQueryResult(conn: WebSocket, data: Map<*, *>, id: String?) = conn.send(
adapter.toJson(
Message(
id = id,
type = MessageType.DATA,
payload = Payload(data = data as Map<String, Any>)
)
)
)
private fun buildQuery(payload: Payload): ExecutionInput.Builder {
val builder = ExecutionInput.Builder().query(payload.query)
if (payload.variables !== null) {
builder.variables(payload.variables)
}
if (payload.operationName !== null) {
builder.operationName(payload.operationName)
}
return builder
}
private fun sendExecutionError(conn: WebSocket, message: Message, executionResult: ExecutionResult) {
Timber.d("ERROR Executing GraphQL query: %s", executionResult.errors.joinToString { it.message })
return conn.send(
adapter.toJson(
Message(
id = message.id,
type = MessageType.ERROR,
payload = Payload(errors = executionResult.errors.map { Error(it.message) }.toTypedArray())
)
)
)
}
Stop Query
private fun stopQuery(conn: WebSocket, message: Message) {
cancelSubscription(conn, message.id)
}