In scala-json-rpc, your shared contract between server and client is called API. API is a trait defining your methods that you want to call remotely.
For example, if you want to have a repository of Foo
and make it available remotely, your API might look like this:
case class Foo(id: String)
trait FooRepositoryAPI {
def add(foo: Foo): Unit
def remove(foo: Foo): Unit
def getAll(): Future[Set[Foo]]
}
If your method returns Future[_]
, it will be JSON-RPC request, meaning you get response from server.
It needs to return Future[_]
because RPC happens remotely, meaning:
- you will get response in the future.
- your RPC might fail, in which case the
Future[_]
should fail.
If your method returns Unit
, it will be JSON-RPC notification, meaning you don't get any response including errors. If you don't need anything returned but still want to know if the RPC succeeded, you can let it return Future[Unit]
.
trait FooRepositoryAPI {
def add(foo: Foo): Future[Unit]
def remove(foo: Foo): Future[Unit]
def getAll(): Future[Set[Foo]]
}
You also want to make sure that your server and client serializes and deserializes JSON in the same way. You can define your JSON serialization and deserialization logic by extending JSONSerializer
trait.
class MyJSONSerializer extends JSONSerializer {
def serialize[T](value: T): Option[String] = {
// Serialize the value into JSON or return None
// ...
}
def deserialize[T](json: String): Option[T] = {
// Deserialize the JSON into value or return None
// ...
}
}
If you just want it to work, you can use UpickleJSONSerializer
which already implements JSONSerializer
using upickle under the hood. See its README page for details.
If you want to define your own logic, make sure the following requirements are met:
- Serialize/deserialize
Left[String, BigDecimal]
as JSON string. - Serialize/deserialize
Right[String, BigDecimal]
as JSON number. - Serialize/deserialize
Some[JSONRPCError]
as JSON-RPC error object. - Serialize/deserialize
None[JSONRPCError]
as JSON null.
To initiate RPCs, you can use JSONRPCClient
class. The class's responsibility is to:
- create API client that can initiate RPCs via its methods.
- make sure response is properly returned from corresponding API method call.
For JSONRPCClient
to work, you need to define:
- how to serialize and deserialize JSON.
- This is the shared logic we already defined above, so we will skip it here.
- how to send request JSON to server.
- how to receive response JSON from server.
We will cover those in the following sections, but here is a piece of code to give you a general idea of how JSONRPCClient
works.
val jsonSerializer = new MyJSONSerializer()
val jsonSender: (String) => Future[Option[String]] = (json: String) => {
// Send the "json" to server and optionally return its response.
// We will cover this in the following sections.
// ...
}
val jsonRPCClient = JSONRPCClient(jsonSerializer, jsonSender)
val fooRepositoryAPI = jsonRPCClient.createAPI[FooRepositoryAPI]
val fooA = Foo("A")
fooRepositoryAPI.add(fooA).onComplete {
case Success(_) => println(s"Successfully added $fooA to the repository")
case _ => println(s"Failed to add $fooA to the repository")
}
Your JSONRPCClient
needs to know how to send JSON to server so that when you call your API method, it knows how to complete the RPC. You can define the logic as JSONSender
function.
type JSONSender = (String) => Future[Option[String]]
The function is supposed to:
- take request JSON as
String
. - send the request JSON to server.
- return optional response from server as
Future[Option[String]]
.- Whenever it failed to send the JSON, you need to return failed
Future[Option[String]]
so that it can fail the API method call.
- Whenever it failed to send the JSON, you need to return failed
For example, if your server is exposing JSON-RPC endpoint via HTTP at POST /jsonrpc, your JSONSender
might look like this:
val jsonSender: (String) => Future[Option[String]] = (json: String) => {
val NoContentStatus = 204
dom.ext.Ajax
.post(url = "/jsonrpc", data = json)
.map(response => {
if (response.status == NoContentStatus) {
None
} else {
Option(response.responseText)
}
})
}
import monix.execution.Scheduler.Implicits.global
import fr.hmil.roshttp.HttpRequest
import fr.hmil.roshttp.body.ByteBufferBody
import io.github.shogowada.scala.jsonrpc.Types.JSONSender
val jsonSender: JSONSender = { json ⇒
val jsonBytes = json.getBytes
HttpRequest("https://httpbin.org/post")
.withHeaders(
"Content-Length" → jsonBytes.length.toString,
"Optional" → "additional",
"Http" → "headers"
)
.post(
ByteBufferBody(
data = java.nio.ByteBuffer.wrap(jsonBytes),
contentType = "application/json; charset=utf-8"
)
)
.map(response ⇒ Option(response.body))
}
If you are returning Future(Some(responseJSON))
from your JSONSender
for all of your requests, it completes the receiving part, so you don't need to worry about this section. This is common if you are using HTTP to send request to server.
If it is not feasible to return response from JSONSender
, you can let it always return Future(None)
, and receive it explicitly by using receive
method of your JSONRPCClient
. This is common if you are using WebSocket or TCP socket to send request to server.
For example, if you are using WebSocket to send and receive JSON-RPC messages, your code might look like this:
def start(jsonRPCWebSocketUrl: String): JSONRPCClient[MyJSONSerializer] {
val webSocket = new dom.WebSocket(jsonRPCWebSocketUrl)
webSocket.onopen = (_: dom.Event) => {
val jsonSerializer = UpickleJSONSerializer()
val jsonSender: (String) => Future[Option[String]] = (json: String) => {
Try(webSocket.send(json)).fold(
throwable => Future.failed(throwable),
_ => Future(None)
)
}
val jsonRPCClient = JSONRPCClient(jsonSerializer, jsonSender)
webSocket.onmessage = (messageEvent: dom.MessageEvent) => {
val message = messageEvent.data.toString
jsonRPCClient.receive(message)
}
// ...
}
}
To handle RPCs, you can use JSONRPCServer
. The class's responsibility is to:
- call appropriate API method implementation for each JSON-RPC request and notification.
- return response for each JSON-RPC request.
For JSONRPCServer
to work, you need to define:
- how to serialize and deserialize JSON.
- This is the shared logic we already defined above, so we will skip it here.
- how to receive request JSON from client.
- how to send response JSON to client.
We will cover those in the following sections, but here is a piece of code to give you a general idea of how JSONRPCServer
works.
val jsonSerializer = new MyJSONSerializer()
val jsonRPCServer = JSONRPCServer(jsonSerializer)
class FooRepositoryAPIImpl extends FooRepositoryAPI {
// Implement FooRepositoryAPI
// ...
}
val fooRepositoryAPI = new FooRepositoryAPIImpl
jsonRPCServer.bindAPI[FooRepositoryAPI](fooRepositoryAPI)
jsonRPCServer.receive(String).onComplete {
case Success(Some(responseJSON)) => {
// RPC is either succeeded or failed, and there is a response for client.
// We will cover this in the following sections.
// ...
}
case Success(None) => {
// RPC is succeeded, but there is nothing to respond.
// This is the case for JSON-RPC notifiation.
}
case Failure(throwable) => {
// Something went wrong, and there was nothing more JSONRPCServer could do.
}
}
To receive request JSON from client, you can use receive
method. The method optionally returns response JSON as Future[Option[String]]
.
Return value | Description |
---|---|
Future(Some(responseJSON: String)) |
Either JSON-RPC request suceeded or failed. responseJSON can be either JSON-RPC response or error. |
Future(None) |
Either JSON-RPC notification succeeded or failed. It has nothing to respond because it was JSON-RPC notification. |
Failure(throwable: Throwable) |
None of above. |
If you are using HTTP to take requests, your code might look like this:
post("/jsonrpc") {
jsonRPCServer.receive(request.body).map {
case Some(responseJSON: String) => Ok(responseJSON) // 200 status
case None => NoContent() // 204 status
}
}
Or, if you are using WebSocket, it might look like this:
def onWebSocketText(text: String) {
jsonRPCServer.receive(text).onComplete {
case Success(Some(responseJSON: String)) => webSocketSession.send(responseJSON)
case Success(None) => // Do nothing
case Failure(throwable) => // Handle error
}
}
If you want to achieve RPC bidirectionally, you need to have both JSONRPCServer
and JSONRPCClient
.
Because this is such a common use case, we also have JSONRPCServerAndClient
. You can create JSONRPCServerAndClient
instance by using JSONRPCServer
and JSONRPCClient
instances.
val jsonRPCServer = JSONRPCServer(/* ... */)
val jsonRPCClient = JSONRPCClient(/* ... */)
val jsonRPCServerAndClient = JSONRPCServerAndClient(jsonRPCServer, jsonRPCClient)
Just as how it looks like, you think of JSONRPCServerAndClient
as JSONRPCServer
and JSONRPCClient
combined. You can still bind your API implementation using bindAPI[API](api: API)
method:
jsonRPCServerAndClient.bindAPI[FooRepositoryAPI](new FooRepositoryAPIImpl)
or use createAPI[API]
to create an API client:
val fooRepositoryAPI = jsonRPCServerAndClient.createAPI[FooRepositoryAPI]
You can receive request JSON and send response JSON by using receive
. The method can also take care of sending response JSON because it already has access to your JSONSender
given to JSONRPCClient
.
jsonRPCServerAndClient.receive(requestOrResponseJSON)