Home

Awesome

Autowire 0.3.3

Scala.js

Autowire is a pair of macros that allows you to perform type-safe, reflection-free RPC between Scala systems. Autowire allows you to write type-safe Ajax/RPC calls that look like:

// shared interface
trait Api{
  def add(x: Int, y: Int, z: Int): Int 
}

// server-side router and implementation 
object Server extends autowire.Server...
object ApiImpl extends Api
  def add(x: Int, y: Int, z: Int) = x + y + z 
}

// client-side callsite

import autowire._ // needed for correct macro application

object Client extends autowire.Client...
Client[Api].add(1, 2, 3).call(): Future[Int]
//         |    |             |
//         |    |             The T is pickled and wrapped in a Future[T]
//         |    The arguments to that method are pickled automatically
//         Call a method on the `Api` trait

This provides a range of nice properties: under-the-hood the macro converts your method calls into RPCs, together with the relevant serialization code, but you do not need to deal with that yourself. Furthermore, since the RPCs appear to be method calls, tools like IDEs are able to work with them, e.g. doing project-wide renames or analysis (jump-to-definition, find-usages, etc.).

Most importantly, the compiler is able to typecheck these RPCs, and ensure that you are always calling them with the correct arguments, and handling the return-value correctly in turn. This removes an entire class of errors.

Autowire is completely agnostic to both the serialization library, and the transport-mechanism.

Getting Started

Autowire is available at the following maven coordinates:

"com.lihaoyi" %% "autowire" % "0.3.3" // for Scala-JVM
"com.lihaoyi" %%% "autowire" % "0.3.3" // Scala.js (1.4.0+) and Scala Native (0.4.0)

This 0.3.3 can be used by publishing locally:

git clone https://github.com/lihaoyi/autowire.git
cd autowire
./mill __.publishLocal

And then add a library dependency in your build.sbt for your shared project:

libraryDependencies ++= Seq(
  "com.lihaoyi" %%% "autowire" % "0.3.3",
  ...
)

Autowire 0.3.3 works for Scala.js 1.x and both Scala 2.12 and 2.13 For Scala.js 0.6.x users, please use autowire 0.2.6.

Autowire works on both Scala-JVM and Scala-JS, meaning you can use it to get type-safe Ajax calls between a browser and your servers.

Minimal Example

Here is a minimal example of Autowire in action, using uPickle to serialize the transferred data:

import autowire._
import upickle._

// shared API interface 
trait MyApi{
  def doThing(i: Int, s: String): Seq[String]
}

// server-side implementation, and router 
object MyApiImpl extends MyApi{
  def doThing(i: Int, s: String) = Seq.fill(i)(s)
}
object MyServer extends autowire.Server[String, upickle.Reader, upickle.Writer]{
  def write[Result: Writer](r: Result) = upickle.write(r)
  def read[Result: Reader](p: String) = upickle.read[Result](p)
  
  val routes = MyServer.route[MyApi](MyApiImpl)
}

// client-side implementation, and call-site
object MyClient extends autowire.Client[String, upickle.Reader, upickle.Writer]{
  def write[Result: Writer](r: Result) = upickle.write(r)
  def read[Result: Reader](p: String) = upickle.read[Result](p)

  override def doCall(req: Request) = {
    println(req)
    MyServer.routes.apply(req)
  }
}

MyClient[MyApi].doThing(3, "lol").call().foreach(println)
// List(lol, lol, lol)

In this example, we have a shared MyApi trait, which contains a trait/interface which is shared by both client and server. The server contains an implementation of this trait, while the client can make calls against the trait using the route macro provided by MyServer.

Although in this example everything is happening locally, this example goes through the entire serialization/de-serialization process when transferring the arguments/return-value. Thus, it is trivial to replace the direct call to MyServer.routes to a remote call over HTTP or TCP or some other transport with MyClient and MyServer/MyApiImpl living on different machines or even running on different platforms (e.g. Scala-JS/Scala-JVM).

Here's a longer example, which takes advantage of autowire's cross-platform-ness to write an interactive client-server web-app.

Note that:

Reference

This section goes deeper into the steps necessary to use Autowire, and should cover extension points not obvious from the above example.

To begin with, the developer has to implement two interfaces, Client:

trait Client[PickleType, Reader[_], Writer[_]] {
  type Request = Core.Request[PickleType]
  def read[Result: Reader](p: PickleType): Result
  def write[Result: Writer](r: Result): PickleType
  
  /**
   * A method for you to override, that actually performs the heavy
   * lifting to transmit the marshalled function call from the [[Client]]
   * all the way to the [[Core.Router]]
   */
  def doCall(req: Request): Future[PickleType]
}

And Server

trait Server[PickleType, Reader[_], Writer[_]] {
  type Request = Core.Request[PickleType]
  type Router = Core.Router[PickleType]
  
  def read[Result: Reader](p: PickleType): Result
  def write[Result: Writer](r: Result): PickleType

  /**
   * A macro that generates a `Router` PartialFunction which will dispatch incoming
   * [[Requests]] to the relevant method on [[Trait]]
   */
  def route[Trait](target: Trait): Router = macro Macros.routeMacro[Trait, PickleType]
}

Both Client and Server are flexible: you get to specify multiple type parameters:

If the serialization library you're using doesn't need Reader[_] or Writer[_] context-bound, you can use autowire.Bounds.None as the context-bound, and it will be filled in automatically. You then need to override the read and write methods of both sides, in order to tell Autowire how to serialize and deserialize your typed values.

Lastly, you need to wire together client and server to get them talking to one another: this is done by overriding Client.doCall, which takes the Request object holding the serialized arguments and returns a Future containing the serialized response, and by using Server.route, which generates a Router PartialFunction which you can then feed Request object into to receive a serialized response in return. These types are defined as

object Core {
  /**
   * The type returned by the [[Server.route]] macro; aliased for
   * convenience, but it's really just a normal `PartialFunction`
   * and can be combined/queried/treated like any other.
   */
  type Router[PickleType] = PartialFunction[Request[PickleType], Future[PickleType]]

  /**
   * A marshalled autowire'd function call.
   *
   * @param path A series of path segments which illustrate which method
   *             to call, typically the fully qualified path of the
   *             enclosing trait followed by the name of the method
   * @param args Serialized arguments for the method that was called. Kept
   *             as a Map of arg-name -> serialized value. Values which
   *             exactly match the default value are omitted, and are
   *             simply re-constituted by the receiver.
   */
  case class Request[PickleType](path: Seq[String], args: Map[String, PickleType])
}

is outside the scope of Autowire, as is how you serialize/deserialize the arguments/return-value using the read and write functions.

In short,

If you still don't fully understand, and need help getting something working, take a look at the complete example.

Error Handling

Assuming that your chosen serialization library behaves as intended, Autowire only throws exceptions when de-serializing data. This could come from many sources: data corrupted in transit, external API users making mistakes, malicious users trying to pen-test your API.

Autowire's de-serialization behavior is documented in the errors it throws:

package autowire

trait Error extends Exception
object Error{
  /**
   * Signifies that something went wrong when de-serializing the
   * raw input into structured data.
   *
   * This can contain multiple exceptions, one for each parameter.
   */
  case class InvalidInput(exs: Param*) extends Exception with Error
  sealed trait Param
  object Param{

    /**
     * Some parameter was missing from the input.
     */
    case class Missing(param: String) extends Param

    /**
     * Something went wrong trying to de-serialize the input parameter;
     * the thrown exception is stored in [[ex]]
     */
    case class Invalid(param: String, ex: Throwable) extends Param
  }
}

These errors are not intended to be end-user-facing. Rather, as far as possible they are maintained as structured data, and it us up to the developer using Autowire to decide how to respond (error page, logging, etc).

Autowire only provides custom error handling on the server side, since there are multiple arguments to validate/aggregate. If something goes wrong on the client during de-serialization, you will get the exception thrown from whichever serialization library you're using.

Autowire supports interface methods with default values. When a RPC call's arguments are left out in favor of defaults, Autowire omits these arguments entirely from the pickled request, and evaluates these arguments on the server when it finds them missing from the request instead of throwing an Error.Param.Missing like it would if that particular parameter did not have default.

Why Autowire

The motivation for Autowire is the fact that Ajax requests between client-side Javascript and server-side something-else has historically been a point of fragility and boilerplate, with problems such as:

Autowire aims to solve all these problems:

Client[Api].fastOp(editor.code).call()

[error] /Client.scala:104: value fastOp is not a member of fiddle.Api
[error]     Client[Api].fastOp(editor.code).call()
[error]                 ^
// endpoint definition
def completeStuff(txt: String, flag: String, offset: Int): List[(String, String)]

// somewhere else inside an `async` block
val res: List[(String, String)] = {
  await(Client[Api].completeStuff(code, flag, intOffset).call())
}

Limitations

Autowire can only serialize and deserialize things that the chosen serialization library can. For example, if you choose to go with uPickle, this means most of the immutable data structures in the standard library and case classes, but circular data structures aren't supported, and arbitrary object graphs don't work.

Autowire does not support method overloading and type parameters on the interfaces/traits used for making the RPCs.

Apart from that, Autowire is a pretty thin layer on top of any existing serialization library and transport layer, and does not project much functionality apart from routing. It is up to the developer using Autowire to decide how he wants to transport the serialized data back and forth, how he wants to respond to errors, etc.

To see a simple example involving a ScalaJS client and Spray server, check out this example:

https://github.com/lihaoyi/workbench-example-app/tree/autowire

Changelog

0.3.3

0.3.2

0.2.6