With the solid foundation we created, it shouldn’t be too hard to implement a straightforward DSL for services; however, in many ways the design of the API is a harder problem, and much more a matter of taste than engineering. In this case our limitations can guide us. Since our limited space and scope prevent us from pursuing more elaborate options like macros and complex base traits, we can instead focus on a thoughtful arrangement of basic Scala patterns: case classes, method chaining, and implicits.
We’ll again rely on Argonaut for serialization and deserialization. As you saw in the previous section, our Response class can hold any kind of value, but we need a good way to transform it into a String for our Server to transmit. We can do this with a little shorthand helper method that will make it simple for us to generate normal successful HTTP responses:
| object ServiceHelpers { |
| def OK[T](body:T, headers:Map[String,String] = Map.empty) |
| (implicit e:EncodeJson[T]):Response[String] = { |
| val b = body.asJson.nospaces |
| Response(200,"OK",headers,b) |
| } |
| } |
The Service class itself is also mostly straightforward: it’s initialized with a sequence of routes, and its dispatch method matches them by looking for a full prefix match on the incoming URL. Although this is a minimalistic design for a URL router, the implementation, by design, would allow a much more sophisticated design with named URL components, and so on, to be supplied. We’ll also provide a small helper method to start the Service and attach it to a Server:
| case class Service(routes:Seq[Route] = Seq.empty) |
| (implicit ec:ExecutionContext) { |
| def dispatch(req:Request[String]):Route = { |
| for (route <- routes) { |
| if (req.method == route.method && req.url.startsWith(route.path)) { |
| println(s"matched route ($route)") |
| return route |
| } |
| } |
| throw new Exception("no match!") |
| } |
| |
| def run(port:Int) = { |
| Server.init(port, this.dispatch) |
| } |
Now, all that’s left is the actual DSL.
We’ll implement our router DSL by appending additional routes onto the Service’s (initially empty) list. Since the OK helper already does the hard work, the GET method handlers are very straightforward:
| def get(path:String)(h:Request[String] => Response[String]):Service = { |
| return Service(this.routes :+ SyncRoute("GET",path,h)) |
| } |
| def getAsync(path:String)(h:Request[String] => Future[Response[String]]): |
| Service = { |
| return Service(this.routes :+ AsyncRoute("GET",path,h)) |
| } |
The POST handlers are just a bit trickier, since they need to decode a request body:
| def post[I,O](path:String)(h:Request[I] => Response[O]) |
| (implicit d:DecodeJson[I], e:EncodeJson[O]):Service = { |
| val handler = (r:Request[String]) => { |
| val parsedRequest = Parse.decodeOption[I](r.body) match { |
| case Some(i) => |
| Request[I](r.method,r.url,r.headers,i) |
| } |
| val resp = h(parsedRequest) |
| Response[String](resp.code, resp.description, |
| resp.headers, resp.body.asJson.nospaces) |
| } |
| return Service(this.routes :+ SyncRoute("POST",path,handler)) |
| } |
| |
| def postAsync[I,O](path:String)(h:Request[I] => Future[Response[O]]) |
| (implicit d:DecodeJson[I], e:EncodeJson[O]):Service = { |
| val handler = (r:Request[String]) => { |
| val parsedRequest = Parse.decodeOption[I](r.body) match { |
| case Some(i) => |
| Request[I](r.method,r.url,r.headers,i) |
| } |
| h(parsedRequest).map { resp => |
| Response[String](resp.code, resp.description, |
| resp.headers, resp.body.asJson.nospaces) |
| } |
| } |
| return Service(this.routes :+ AsyncRoute("POST",path,handler)) |
| } |
With that, we can put together a true microservice composed of synchronous and asynchronous routes:
| object Main { |
| import LibUVConstants._, LibUV.uv_run, ServiceHelpers._ |
| implicit val ec = EventLoop |
| |
| def main(args:Array[String]):Unit = { |
| Service() |
| .getAsync("/async/") { r => Future(OK( |
| Map("asyncMessage" -> s"got (async routed) request $r") |
| ))} |
| .get("/") { r => OK( |
| Map("message" -> s"got (routed) request $r") |
| )} |
| .run(9999) |
| uv_run(EventLoop.loop, UV_RUN_DEFAULT) |
| println("done") |
| } |
| } |
You can test this out with curl, or a browser, and get a sense of it’s flexibility and power. Argonaut’s JSON codecs, in particular, are flexible enough to handle lists, maps, case classes, and many other common data structures without boilerplate, which can make our services as compact as you might write in a scripting language, like Python or JavaScript, but with all the power and type-safety of Scala.