Service descriptors

§Service Descriptors

Lagom services are described by an interface, known as a service descriptor. This interface not only defines how the service is invoked and implemented, it also defines the metadata that describes how the interface is mapped down onto an underlying transport protocol. Generally, the service descriptor, its implementation and consumption should remain agnostic to what transport is being used, whether that’s REST, websockets, or some other transport. Let’s take a look at a simple descriptor:

import com.lightbend.lagom.scaladsl.api._

trait HelloService extends Service {
  def sayHello: ServiceCall[String, String]

  override def descriptor = {
    import Service._
    named("hello").withCalls(
      call(sayHello)
    )
  }
}

This descriptor defines a service with one call, the sayHello call. sayHello is a method that returns something of type ServiceCall, this is a representation of the call that can be invoked when consuming the service, and implemented by the service itself. This is what the interface looks like:

trait ServiceCall[Request, Response] {
  def invoke(request: Request): Future[Response]
}

An important thing to note here is that invoking the sayHello method does not actually invoke the call, it simply gets a handle to the call, which can then be invoked using the invoke method.

ServiceCall takes two type parameters, Request and Response. The Request parameter is the type of the incoming request message, and the Response parameter is the type of the outgoing response message. In the example above, these are both String, so our service call just handles simple text messages.

While the sayHello method describes how the call will be programmatically invoked or implemented, it does not describe how this call gets mapped down onto the transport. This is done by providing an implementation of the descriptor call, whose interface is described by Service.

You can see that we’re returning a service named hello, and we’re describing one call, the sayHello call. Because this service is so simple, in this case we don’t need to do anything more than simply passing the Service call sayHello defined above in the example as a method reference to the call method.

§Call identifiers

Each service call needs to have an identifier. An identifier is used to provide routing information to the implementation of the client and the service, so that calls over the wire can be mapped to the appropriate call. Identifiers can be a static name or path, or they can have dynamic components, where dynamic path parameters are extracted from the path and passed to the service call methods.

The simplest type of identifier is a name, and by default, that name is set to be the same name as the name of the method on the interface that implements it. In the example above, we’ve used the call method to create a service call with a name of sayHello. A custom name can also be supplied, by using the namedCall method:

named("hello").withCalls(
  namedCall("hello", sayHello)
)

In this case, we’ve named it hello, instead of the default of sayHello. When implemented using REST, this will mean this call will have a path of /hello.

§Path based identifiers

The second type of identifier is a path based identifier. This uses a URI path and query string to route calls, and from it dynamic path parameters can optionally be extracted out. They can be configured using the pathCall method.

Dynamic path parameters are extracted from the path by declaring dynamic parts in the path. These are prefixed with a colon, for example, a path of /order/:id has a dynamic part called id. Lagom will extract this parameter from the path, and pass it to the service call method. In order to convert it to the type accepted by the method, Lagom will use an implicitly provided PathParamSerializer. Lagom includes many PathParamSerializer’s out of the box, such as for String, Long, Int, Boolean and UUID. Here’s an example of extracting a long parameter from the path and passing it to a service call:

def getOrder(orderId: Long): ServiceCall[NotUsed, Order]

override def descriptor = {
  import Service._
  named("orders").withCalls(
    pathCall("/order/:id", getOrder _)
  )
}

Note that this time we’re using an eta-expanded reference to the method. This is because the method takes a parameter.

Multiple parameters can of course be extracted out, these will be passed to your service call method in the order they are extracted from the URL:

def getItem(orderId: Long, itemId: String): ServiceCall[NotUsed, Item]

override def descriptor = {
  import Service._
  named("orders").withCalls(
    pathCall("/order/:orderId/item/:itemId", getItem _)
  )
}

Query string parameters can also be extracted from the path, using a & separated list after a ? at the end of the path. For example, the following service call uses query string parameters to implement paging:

def getItems(orderId: Long, pageNo: Int, pageSize: Int): ServiceCall[NotUsed, Seq[Item]]

override def descriptor = {
  import Service._
  named("orders").withCalls(
    pathCall("/order/:orderId/items?pageNo&pageSize", getItems _)
  )
}

When you use call, namedCall or pathCall, if Lagom maps that down to REST, Lagom will make a best effort attempt to map it down to REST in a semantic fashion. So for example, if there is a request message it will use the POST method, whereas if there’s none it will use GET.

§REST identifiers

The final type of identifier is a REST identifier. REST identifiers are designed to be used when creating semantic REST APIs. They use both a path, as with the path based identifier, and a request method, to identify them. They can be configured using the restCall method:

def addItem(orderId: Long): ServiceCall[Item, NotUsed]
def getItem(orderId: Long, itemId: String): ServiceCall[NotUsed, Item]
def deleteItem(orderId: Long, itemId: String): ServiceCall[NotUsed, NotUsed]

def descriptor = {
  import Service._
  import com.lightbend.lagom.scaladsl.api.transport.Method
  named("orders").withCalls(
    restCall(Method.POST,   "/order/:orderId/item",         addItem _),
    restCall(Method.GET,    "/order/:orderId/item/:itemId", getItem _),
    restCall(Method.DELETE, "/order/:orderId/item/:itemId", deleteItem _)
  )
}

§Messages

Every service call in Lagom has a request message type and a response message type. When the request or response message isn’t used, the akka.NotUsed can be used in their place. Request and response message types fall into two categories, strict and streamed.

§Strict messages

A strict message is a single message that can be represented by a simple Scala object, typically a case class. The message will be buffered into memory, and then parsed, for example, as JSON. When both message types are strict, the call is said to be a synchronous call, that is, a request is sent and received, then a response is sent and received. The caller and callee have synchronized in their communication.

So far, all of the service call examples we’ve seen have used strict messages, for example, the order service descriptors above accept and return items and orders. The input value is passed directly to the service call, and returned directly from the service call, and these values are serialized to a JSON buffer in memory before being sent, and read entirely into memory before being deserialized back from JSON.

§Streamed messages

A streamed message is a message of type Source. Source is an Akka streams API that allows asynchronous streaming and handling of messages. Here’s an example streamed service call:

import akka.NotUsed
import akka.stream.scaladsl.Source

def tick(interval: Int): ServiceCall[String, Source[String, NotUsed]]

def descriptor = {
  import Service._
  named("clock").withCalls(
    pathCall("/tick/:interval", tick _)
  )
}

This service call has a strict request type and a streamed response type. An implementation of this might return a Source that sends the input tick message String at the specified interval.

A bidirectional streamed call might look like this:

import akka.NotUsed
import akka.stream.scaladsl.Source

def sayHello: ServiceCall[Source[String, NotUsed], Source[String, NotUsed]]

def descriptor = {
  import Service._
  named("hello").withCalls(
    call(this.sayHello)
  )
}

In this case, the server might return a Source that converts every message received in the request stream to messages prefixed with Hello.

Lagom will choose an appropriate transport for the stream, typically, this will be WebSockets. WebSockets support bidirectional streaming, and so are a good general purpose option for streaming. When only one of the request or response message is streamed, Lagom will implement the sending and receiving of the strict message by sending or receiving a single message, and then leaving the WebSocket open until the other direction closes. Otherwise, Lagom will close the WebSocket when either direction closes.

§Message serialization

Message serializers for requests and responses are provided using type classes. Each of the call, namedCall, pathCall and restCall methods take an implicit MessageSerializer for each of the request and response messages. Out of the box Lagom provides a serializer for String messages, as well as serializers that implicitly convert a Play JSON Format type class to a message serializer.

§Using Play JSON

Play JSON provides a functional type class based library for composing JSON formatters. For detailed documentation on how to use this library, see the Play documentation. For now, we will just look at how to define JSON formats for case classes using Play’s JSON format macro.

Let’s say you have a User case class that looks like this:

case class User(
  id: Long,
  name: String,
  email: Option[String]
)

A Play JSON format can be defined on the User companion object like so:

object User {
  import play.api.libs.json._
  implicit val format: Format[User] = Json.format[User]
}

This format will generate and parse JSON in the following format:

{
  "id": 12345,
  "name": "John Smith",
  "email": "[email protected]"
}

Fields can be made optional by making them of type Option, this will mean the format will not fail to parse the JSON if the property is not present, and when it generates JSON, it will simply not generate that property.

By defining the format on the User companion object, we can ensure that this format will be automatically used whenever it is required, due to Scala’s implicit scoping rules. This means that aside from declaring the format, no further work needs to be done to ensure that this format will be used for the MessageSerializer.

Note that if your case class references another, non primitive type, such as another case class, you’ll need to also define a format for that case class.

§Writing custom message serializers

You can also write custom message serializers, for example, to use protocol buffers or other message format types. For more information, see the message serializers documentation.

Found an error in this documentation? The source code for this page can be found here. Please feel free to edit and contribute a pull request.