Generating a Server

guardrail-generated servers come in two parts: a Resource and a Handler. The Resource contains all the routing logic, accepting a Handler as an argument to the route function in order to provide an HTTP service in whichever supported HTTP framework you’re hosting your service in.

The following is an example from the akka-http server generator:


// The `Handler` trait is fully abstracted from the underlying http framework. As a result, with the exception of some
// structural alterations (`F[_]` instead of `Future[_]` as the return type) the same handlers can be used with
// different `Resource` implementations from different framework generators. This permits greater compatibility between
// different frameworks without changing your business logic.
  
trait UserHandler {
  def createUser(respond: UserResource.createUserResponse.type)(body: User): scala.concurrent.Future[UserResource.createUserResponse]
  def createUsersWithArrayInput(respond: UserResource.createUsersWithArrayInputResponse.type)(body: IndexedSeq[User]): scala.concurrent.Future[UserResource.createUsersWithArrayInputResponse]
  def createUsersWithListInput(respond: UserResource.createUsersWithListInputResponse.type)(body: IndexedSeq[User]): scala.concurrent.Future[UserResource.createUsersWithListInputResponse]
  def loginUser(respond: UserResource.loginUserResponse.type)(username: String, password: String): scala.concurrent.Future[UserResource.loginUserResponse]
  def logoutUser(respond: UserResource.logoutUserResponse.type)(): scala.concurrent.Future[UserResource.logoutUserResponse]
  def getUserByName(respond: UserResource.getUserByNameResponse.type)(username: String): scala.concurrent.Future[UserResource.getUserByNameResponse]
  def updateUser(respond: UserResource.updateUserResponse.type)(username: String, body: User): scala.concurrent.Future[UserResource.updateUserResponse]
  def deleteUser(respond: UserResource.deleteUserResponse.type)(username: String): scala.concurrent.Future[UserResource.deleteUserResponse]
}

object UserResource {
  def routes(handler: UserHandler)(implicit mat: akka.stream.Materializer): Route = {
    {
      post(path("v2" / "user")(entity(as[User](createUserDecoder)).apply(body => complete(handler.createUser(createUserResponse)(body)))))
    } ~ {
      post(path("v2" / "user" / "createWithArray")(entity(as[IndexedSeq[User]](createUsersWithArrayInputDecoder)).apply(body => complete(handler.createUsersWithArrayInput(createUsersWithArrayInputResponse)(body)))))
    } ~ {
      post(path("v2" / "user" / "createWithList")(entity(as[IndexedSeq[User]](createUsersWithListInputDecoder)).apply(body => complete(handler.createUsersWithListInput(createUsersWithListInputResponse)(body)))))
    } ~ {
      get(path("v2" / "user" / "login")((parameter(Symbol("username").as[String](stringyJsonUnmarshaller.andThen(unmarshallJson[String]))) & parameter(Symbol("password").as[String](stringyJsonUnmarshaller.andThen(unmarshallJson[String])))).apply((username, password) => discardEntity(complete(handler.loginUser(loginUserResponse)(username, password))))))
    } ~ {
      get(path("v2" / "user" / "logout")(discardEntity(complete(handler.logoutUser(logoutUserResponse)()))))
    } ~ {
      get(path("v2" / "user" / Segment).apply(username => discardEntity(complete(handler.getUserByName(getUserByNameResponse)(username)))))
    } ~ {
      put(path("v2" / "user" / Segment).apply(username => entity(as[User](updateUserDecoder)).apply(body => complete(handler.updateUser(updateUserResponse)(username, body)))))
    } ~ {
      delete(path("v2" / "user" / Segment).apply(username => discardEntity(complete(handler.deleteUser(deleteUserResponse)(username)))))
    }
  }
  `...`
}

As all parameters are provided as arguments to the function stubs in the trait, there’s no concern of forgetting to extract a query string parameter, introducing a typo in a form parameter name, or forgetting to close the bytestream for the streaming HTTP Request.

Separation of business logic

Providing an implementating of a function with a well-defined set of inputs and outputs is natural for any developer. By reducing the scope of the interface a developer writes against, implementations are more clear and concise.

Furthermore, by providing business logic as an implementation of an abstract class, unit tests can test the routing layer and business logic independently, by design.

API structure slip is impossible

As parameters are explicitly provided as arguments to functions in Handlers, any alteration to parameters constitute a new function interface that must be implemented. As a result, if providing an implementation for an externally managed specification, the compiler informs when a previously written function is no longer sufficient.

By representing different response codes and structures as members of a sealed trait, it’s impossible to return a structure that violates the specification, even for less frequently used response codes.

Finally, describing an endpoint in your specification without providing an implementation for it is a compiler error. This prevents reduction of functionality due to refactors, human error, or miscommunication with other teams.

Generating test-only (real) server mocks for unit tests

Often, we’ll also want to have mock HTTP clients for use in unit tests. Mocking requires stringent adherence to the specification, otherwise our mock clients are unrepresentative of the production systems they are intending to mock. The following is an example of a “mock” HTTP Client generated by guardrail; it speaks real HTTP, though doesn’t need to bind to a port in order to run. This permits parallelized tests to be run without concern of port contention.

val userRoutes: Route = UserResource.routes(new UserHandler {
  override def getUserByName(respond: UserResource.getUserByNameResponse.type)(username: String): scala.concurrent.Future[UserResource.getUserByNameResponse] = {
    if (username == "foo") {
      Future.successful(respond.OK(User(id=Some(1234L), username=Some("foo"))))
    } else {
      Future.successful(respond.NotFound)
    }
  }
})
val userHttpClient: HttpRequest => Future[HttpResponse] = Route.asyncHandler(userRoutes)
val userClient: UserClient = UserClient.httpCLient(userHttpClient)
val getUserResponse: EitherT[Future, Either[Throwable, HttpResponse], User] = userClient.getUserByName("foo").map(_.fold(user => user))
val user: User = getUserResponse.value.futureValue.right.value // Unwraps `User(id=Some(1234L), username=Some("foo"))` using scalatest's `ScalaFutures` and `EitherValues` unwrappers.

This strategy of mocking ensures we follow the spec, even when the specification changes. This means not only more robust tests, but also tests that communicate failures via compiler errors instead of at runtime. Having a clear separation of where errors can come from permits trusting our tests more. If the tests compile, any and all errors that occur are in the domain of business logic.

One other strategy for testing non-guardrail generated clients is to bind userRoutes from above to a port, run tests that use hand-rolled or vendor-supplied HTTP clients, then unbind the port when the test ends:

val binding: ServerBinding =
  Http().bindAndHandle(userRoutes, "localhost", 1234).futureValue

// run tests

binding.unbind().futureValue

A note about scalatest integration

akka-http

The default ExceptionHandler in akka-http swallows exceptions, so if you intend to fail() tests from inside guardrail-generated HTTP Servers, you’ll likely want to have the following implicit in scope:

implicit def exceptionHandler: ExceptionHandler = new ExceptionHandler {
  def withFallback(that: ExceptionHandler): ExceptionHandler = this
  def seal(settings: RoutingSettings): ExceptionHandler = this

  def isDefinedAt(error: Throwable) = error.isInstanceOf[org.scalatest.TestFailedException]
  def apply(error: Throwable) = throw error
}

This passes all TestFailedExceptions through to the underlying infrastructure. In our tests, when we call:

val userClient: UserClient = UserClient.httpCLient(userHttpClient)
val getUserResponse: EitherT[Future, Either[Throwable, HttpResponse], User] = userClient.getUserByName("foo")
val user: User = getUserResponse.map(_.fold(user => user)).value.futureValue.right.value

futureValue will raise the TestFailedException with the relevant stack trace.

Prev: Sample API specification Next: Generating clients