This article is an introduction on how to use Twilio’s Guardrail to safely generate and maintain a http4s REST API server. I wanted to write this article as a reference for my future self and others who are interested in this technology.
tl;dr - I just want the code!
Find the finished project at guardrail-http4s-tutorial
The case for Guardrail
To quote from Guardrail’s website:
Guardrail is a code generation tool, capable of reading from OpenAPI/Swagger specification files and generating Scala source code, primarily targeting the akka-http and http4s web frameworks, using circe for JSON encoding/decoding.
That’s nice and all, but Swagger is already capable of generating Scala code, why does Guardrail exist at all?
In two words type safety , as we will see it is impossible to do the wrong thing when working with Guardrail’s generated code because the compiler’s type checker will prevent us from making mistakes.
More important, when using Guardrail, we are forced to develop API first and our OpenAPI/Swagger specification functions as the single source of truth for our API’s.
Now, without further ado, let’s build a http4s server using Guardrail!
Prerequisites and setup
For this tutorial we will needsbt. To save some time we are going to use the http4s g8 template, in your terminal do the following:
> sbt new http4s/http4s.g8
I’m going to use the defaults. If you want to have different names, the paths and filenames in this tutorial might be different for you. When sbt
is done open the newly created directory. Start with deleting the standard routes since we are not going to use them. Delete the following files and directories:
src/main/scala/com/example/quickstart/HelloWorld.scala
src/main/scala/com/example/quickstart/Jokes.scala
src/main/scala/com/example/quickstart/QuickstartRoutes.scala
src/test
In QuickstartServer.scala
, remove the references to files you just deleted so that we are left with the following file:
package com.example.quickstart import cats.effect.{ConcurrentEffect, Effect, ExitCode, IO, IOApp, Timer, ContextShift} import cats.implicits._ import fs2.Stream import org.http4s.client.blaze.BlazeClientBuilder import org.http4s.HttpRoutes import org.http4s.implicits._ import org.http4s.server.blaze.BlazeServerBuilder import org.http4s.server.middleware.Logger import scala.concurrent.ExecutionContext.global object QuickstartServer { def stream[F[_]: ConcurrentEffect](implicit T: Timer[F], C: ContextShift[F]): Stream[F, Nothing] = { for { client <- BlazeClientBuilder[F](global).stream httpApp = ( HttpRoutes.empty[F] // We will add our own routes here later ).orNotFound finalHttpApp = Logger.httpApp(true, true)(httpApp) exitCode <- BlazeServerBuilder[F] .bindHttp(8080, "0.0.0.0") .withHttpApp(finalHttpApp) .serve } yield exitCode }.drain }
Now let’s see if we can still run the application:
> sbt run
The application should compile and we should see some output like this:
[scala-execution-context-global-92] INFO o.h.s.b.BlazeServerBuilder - http4s v0.20.0 on blaze v0.14.0 started at http://[0:0:0:0:0:0:0:0]:8080/
Congratulations! We have a working http4s application. Right now it just sits there doing nothing, so let us create some endpoints!
API specifications and the Guardrail sbt plugin
For this tutorial, we are going to recreate the Hello World endpoint that we deleted earlier. Save the following specification as src/main/resources/api.yaml
:
openapi: "3.0.0" info: title: http4s Guardrail example version: 0.0.1 tags: - name: hello paths: /hello: get: tags: [hello] x-scala-package: hello operationId: getHello summary: Returns a hello message responses: 200: description: Hello message content: application/json: schema: $ref: '#/components/schemas/HelloResponse' components: schemas: HelloResponse: type: object properties: message: type: string required: - message
Two things to notice in the specification are:
- We must provide an
operationId
for our operation - It is good practice to provide an
x-scala-package
value to group related operations together.
For the code generation, we are going to use the sbt-guardrail plugin, toproject.plugins.sbt
add the following line:
addSbtPlugin("com.twilio" % "sbt-guardrail" % "0.46.0")
To build.sbt
, append the following lines:
guardrailTasks in Compile := List( ScalaServer( specPath = (Compile / resourceDirectory).value / "api.yaml", pkg = "com.example.quickstart.endpoints", framework = "http4s", tracing = false ) )
And add the following dependency to the existing libraryDependencies
:
"io.circe" %% "circe-java8" % CirceVersion,
To see the code generator in action, run:
> sbt compile
And take a look in thetarget/scala-2.12/src_managed/main/com/example/quickstart
directory, this is where our generated code lives, lets see what is there:
The definitions
directory contains the case classes that are used as request and response bodies and helper code for serialization and deserialization. For example, we defined a HelloResponse
schema in the API specification we got a corresponding HelloResponse.scala
file.
The hello
package got its name from the x-scala-package
value.hello/Routes.scala
contains a trait
with methods that we must implement. The methods in this trait correspond the operations / operationId
s in the API specification.
Http4sImplicits.scala
and Implicits.scala
contain, well, implicits. They are there to glue everything together.
So far so good, now we must actually implement the endpoint we generated.
Implementing the generated endpoint
Create a new file atsrc/main/scala/com/example/quickstart/endpoints/hello/HelloHandlerImpl.scala
with the following content:
package com.example.quickstart.endpoints.hello import cats.Applicative import cats.implicits._ import com.example.quickstart.endpoints.definitions.HelloResponse class HelloHandlerImpl[F[_] : Applicative]() extends HelloHandler[F] { override def getHello(respond: GetHelloResponse.type)(): F[GetHelloResponse] = { for { message <- "Hello, world".pure[F] } yield respond.Ok(HelloResponse(message)) } }
What we’ve done here is implement the generated HelloHandler
. Looking at the signature of the getHello
method we can see Guardrail genius, everything is typed! If this still doesn’t click with you, try to rewrite the change the code to respond with something else than a 200 OK
and have it compile (hint, you can’t).
Before we forget, lets add our hello routes to the application, opensrc/main/scala/com/example/quickstart/QuickstartServer.scala
and replace it with:
package com.example.quickstart import cats.effect.{ConcurrentEffect, Effect, ExitCode, IO, IOApp, Timer, ContextShift} import cats.implicits._ import com.example.quickstart.endpoints.hello.{HelloHandlerImpl, HelloResource} import fs2.Stream import org.http4s.client.blaze.BlazeClientBuilder import org.http4s.HttpRoutes import org.http4s.implicits._ import org.http4s.server.blaze.BlazeServerBuilder import org.http4s.server.middleware.Logger import scala.concurrent.ExecutionContext.global object QuickstartServer { def stream[F[_]: ConcurrentEffect](implicit T: Timer[F], C: ContextShift[F]): Stream[F, Nothing] = { for { client <- BlazeClientBuilder[F](global).stream httpApp = ( new HelloResource().routes(new HelloHandlerImpl()) ).orNotFound finalHttpApp = Logger.httpApp(true, true)(httpApp) exitCode <- BlazeServerBuilder[F] .bindHttp(8080, "0.0.0.0") .withHttpApp(finalHttpApp) .serve } yield exitCode }.drain }
What changed is that we added the linenew HelloResource().routes(new HelloHandlerImpl())
to the httpApp
.
Now we can run the application again:
> sbt run
And once it’s running we can test our endpoint using curl
:
> curl http://localhost:8080/hello {"message":"Hello, world"}%
🎉 Success! Everything is well in the world now. That is, until the API requirements change…
API specification changes
Guardrail makes changing the API specification a breeze. Earlier I said that we were recreating the standard hello world routes provided by the g8 http4s template. But we are missing something, namely, we want the /hello
endpoint to respond with any given name. Let’s change the API specification atsrc/main/resources/api.yaml
to
openapi: "3.0.0" info: title: http4s Guardrail example version: 0.0.1 tags: - name: hello paths: /hello: get: tags: [hello] x-scala-package: hello operationId: getHello summary: Returns a hello message parameters: - $ref: '#/components/parameters/NameParam' responses: 200: description: Hello message content: application/json: schema: $ref: '#/components/schemas/HelloResponse' components: parameters: NameParam: name: name in: query description: Name to greet schema: type: string schemas: HelloResponse: type: object properties: message: type: string required: - message
What changed is that we added a parameter to the /hello
endpoint.
If we trigger the code generator again by calling:
> sbt compile
Guardrail will inform us that we are changing an existing file:
Warning: The file ~/Developer/quickstart/target/scala-2.12/src_managed/main/com/example/quickstart/endpoints/hello/Routes.scala contained different content than was expected. Existing file: ): F[GetHelloResponse] }\nclass HelloResource[F[_]] New file : name: Option[String] = None): F[GetHelloResponse]
Followed by a bunch of compiler errors. This is actually the compiler telling us that we need to change our implementation because it is out of sync with the generated code. Nice. Opensrc/main/scala/com/example/quickstart/endpoints/HelloHandlerImpl.scala
and replace it with the following:
package com.example.quickstart.endpoints.hello import cats.Applicative import cats.implicits._ import com.example.quickstart.endpoints.definitions.HelloResponse class HelloHandlerImpl[F[_] : Applicative]() extends HelloHandler[F] { override def getHello(respond: GetHelloResponse.type)(name: Option[String] = None): F[GetHelloResponse] = { for { message <- s"Hello, ${name.getOrElse("world")}".pure[F] } yield respond.Ok(HelloResponse(message)) } }
Now run the application again:
> sbt run
And once it is running we can try to get a personalized greeting:
> curl http://localhost:8080/hello\?name\=Kay {"message":"Hello, Kay"}%
And that is how easy it is to update your API specification!
Conclusion
In a few minutes we were able to create a simple REST API server with safely typed endpoints generated from an API specification. Better yet, we now have a basis to build our application on. As we have seen Guardrail makes our lives easier by forcing us to stay true to our API specification.
Guardrail is production ready IMO but can be rough around the edges sometimes. If you like Guardrail, they are looking for contributions.
You can find the finished project at guardrail-http4s-tutorial.
Thank you for reading.
Top comments (0)