OAuth2 Scala application with sttp-oauth2, part 1
Building simple Scala web application with OAuth2 Github login - part 1
Welcome to the first article in the series. In following posts we’re going to build a web application using Scala.
Goal
In this series we’ll learn how to:
- Start project from scratch
- Create web service
- Implement user login using OAuth2 authorization code grant
- Interact with other web services via HTTP calls, using JSON for data serialization
- Refactor the application to follow tagless final pattern
Tech stack
We’re going to build our application using functional programming techniques and Final Tagless encoding.
To build a web application, we need some libraries:
- http4s for HTTP server
- tapir for API endpoint encoding
- Cats library for functional programming
- Cats Effect asynchronous runtime
- sttp HTTP client
- sttp-oauth2 OAuth2 client
Bootstrap
In this series we’ll manage our project using sbt. To create initial project structure, we can call sbt new sbt/scala-seed.g8 --name=sample-webapp
.
Directory structure
When we’re done, we should have a directory structure like on the listing below
.
├── build.sbt
├── project
│ ├── build.properties
│ └── Dependencies.scala
└── src
├── main
│ └── scala
│ └── example
│ └── Hello.scala
└── test
└── scala
└── example
Things worth explaining in this structure:
build.sbt
main file describing build definition.project/build.properties
this file declares which sbt version should be used for the projectproject/Dependencies.scala
defines source code dependencies. Contents of this file might as well be placed directly inbuild.sbt
. Later on we’ll justify why it makes sense here.- All files in
src/main/scala
- sources of our application, whereexample
directory reflects the package name - All files in
src/test/scala
- sources for test files for our application, we won’t be focusing on it in this article
This structure is described in detail in sbt documentation.
Dependencies
The defaults used by Giter8 template might be outdated. Make sure you have updated scalaVersion
in build.sbt
ThisBuild / scalaVersion := "2.13.6"
ThisBuild / version := "0.1.0-SNAPSHOT"
ThisBuild / organization := "com.example"
ThisBuild / organizationName := "example"
Also you need to update sbt version in build.properties
sbt.version=1.5.4
Now let’s modify Dependencies.scala
to include all necessary dependencies:
import sbt._
object Versions {
val Cats = "2.3.0"
val CatsEffect = "2.5.1"
val Circe = "0.14.1"
val Tapir = "0.18.0-M15"
val Sttp = "3.3.6"
val SttpOAuth2 = "0.10.0"
}
object Dependencies {
private val cats = Seq(
"org.typelevel" %% "cats-core" % Versions.Cats
)
private val catsEffect = Seq(
"org.typelevel" %% "cats-effect" % Versions.CatsEffect
)
private val circe = Seq(
"io.circe" %% "circe-generic" % Versions.Circe
)
private val tapir = Seq(
"com.softwaremill.sttp.tapir" %% "tapir-core" % Versions.Tapir,
"com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % Versions.Tapir
)
private val sttp = Seq(
"com.softwaremill.sttp.client3" %% "async-http-client-backend-cats-ce2" % Versions.Sttp
)
private val sttpOAuth2 = Seq(
"com.ocadotechnology" %% "sttp-oauth2" % Versions.SttpOAuth2
)
val appDependencies =
cats ++ catsEffect ++ circe ++ tapir ++ sttp ++ sttpOAuth2
}
This notation is especially useful for more mature projects, where Scala Steward is used, as it makes it easier to maintain automatic version updates.
In case you wonder why we didn’t directly include something like:
"org.http4s" %% "http4s-blaze-server" % Versions.Http4s
It’s because of transitive dependencies - in this case tapir-http4s-server
provides http4s.
Now that we have our dependencies defined, we should adjust libraryDependencies
in build.sbt
:
lazy val root = (project in file("."))
.settings(
name := "sample-webapp",
libraryDependencies ++= Dependencies.appDependencies
)
As of this moment, our project has everything it needs for proper development. Here’s a relevant commit in demo project with all changes applied until now.
You can view all downloaded dependencies (direct and transitive ones) by launching sbt
console and running dependencyTree
. It looks somewhat like this:
sbt:sample-webapp> dependencyTree
[info] com.example:sample-webapp_2.13:0.1.0-SNAPSHOT [S]
[info] +-com.ocadotechnology:sttp-oauth2_2.13:0.10.0 [S]
[info] | +-com.softwaremill.sttp.client3:circe_2.13:3.2.3 [S]
[info] | | +-com.softwaremill.sttp.client3:core_2.13:3.2.3 (evicted by: 3.3.6)
[info] | | +-com.softwaremill.sttp.client3:core_2.13:3.3.6 [S]
[info] | | | +-com.softwaremill.sttp.model:core_2.13:1.4.7 [S]
[info] | | | +-com.softwaremill.sttp.shared:core_2.13:1.2.5 [S]
[info] | | | +-com.softwaremill.sttp.shared:ws_2.13:1.2.5 [S]
[info] | | | +-com.softwaremill.sttp.model:core_2.13:1.4.7 [S]
[info] | | | +-com.softwaremill.sttp.shared:core_2.13:1.2.5 [S]
[info] | | |
[info] | | +-com.softwaremill.sttp.client3:json-common_2.13:3.2.3 [S]
[info] | | | +-com.softwaremill.sttp.client3:core_2.13:3.2.3 (evicted by: 3.3.6)
[info] | | | +-com.softwaremill.sttp.client3:core_2.13:3.3.6 [S]
[info] | | | +-com.softwaremill.sttp.model:core_2.13:1.4.7 [S]
[info] | | | +-com.softwaremill.sttp.shared:core_2.13:1.2.5 [S]
[info] | | | +-com.softwaremill.sttp.shared:ws_2.13:1.2.5 [S]
[info] | | | +-com.softwaremill.sttp.model:core_2.13:1.4.7 [S]
[info] | | | +-com.softwaremill.sttp.shared:core_2.13:1.2.5 [S]
[info] | | |
[info] | | +-io.circe:circe-core_2.13:0.13.0 (evicted by: 0.14.1)
[info] | | +-io.circe:circe-core_2.13:0.14.1 [S]
[info] | | | +-io.circe:circe-numbers_2.13:0.14.1 [S]
[info] | | | +-org.typelevel:cats-core_2.13:2.6.1
...
HTTP Server
Our initial server implementation will be inspired by HelloWorldHttp4sServer.scala implementation from tapir examples.
Let’s start with minimal example:
package example
import cats.effect._
object Hello extends IOApp {
override def run(args: List[String]): IO[ExitCode] = IO.pure(ExitCode.Success)
}
This is a very basic application, that does literally nothing. It’s a main object, that uses Cats Effect runtime.
Endpoint
We’ll start building our first iteration of web application by defining some endpoint. For that we’ll need to import sttp.tapir._
for all relevant types used to describe our API. Please refer to tapir documentation for tapir DSL description.
Our minimal endpoint responds to GET
requests on /hello
path. It requires name
query param typed as a String
, and responds with String
body. The return type is simple string, thus the returned Content-Type
will be text/plain
.
import cats.effect._
import sttp.tapir._
object Hello extends IOApp {
val helloWorld: Endpoint[String, Unit, String, Any] =
endpoint
.get
.in("hello")
.in(query[String]("name"))
.out(stringBody)
override def run(args: List[String]): IO[ExitCode] = IO.pure(ExitCode.Success)
}
Logic
Next step - we need some logic for our endpoint, nothing sophisticated. Surprisingly often when doing functional programming, what you need to do is just follow the types, and the implementation will come up on its own.
Looking at the endpoint type: Endpoint[String, Unit, String, Any]
we can generalize it as Endpoint[InputType, ErrorType, ResultType, Capabilities]
. Endpoint logic implementation should always look like: InputType => F[Either[ErrorType, ResultType]]
. Where F
describes the runtime effect. In our case, the runtime is described by IO
monad. We don’t care about Capabilities
here, those will be useful in case you wanted to introduce streaming support.
In our case the only reasonable implementation type would be String => IO[Either[Unit, String]]
. Let’s implement it then:
def endpointLogic(name: String) = IO(s"Hello, $name!".asRight[Unit])
To make it work, we need to import import cats.syntax.all._
to have .asRight
in scope.
Route
Now that we have both the description and the logic, natural next step would be to connect them together. This requires some web server, here’s where Http4s comes into play.
Here’s how you create routes out of endpoint description and logic:
val helloWorldRoutes: HttpRoutes[IO] =
Http4sServerInterpreter.toRoutes(helloWorld)(endpointLogic _)
Two imports are necessary to make it work:
import sttp.tapir.server.http4s.Http4sServerInterpreter
import org.http4s.HttpRoutes
To wrap up, this is what we’ve got until now:
package example
import cats.effect._
import sttp.tapir._
import sttp.tapir.server.http4s.Http4sServerInterpreter
import org.http4s.HttpRoutes
import cats.syntax.all._
object Hello extends IOApp {
val helloWorld: Endpoint[String, Unit, String, Any] =
endpoint
.get
.in("hello")
.in(query[String]("name"))
.out(stringBody)
def endpointLogic(name: String) = IO(s"Hello, $name!".asRight[Unit])
val helloWorldRoutes: HttpRoutes[IO] =
Http4sServerInterpreter.toRoutes(helloWorld)(endpointLogic _)
override def run(args: List[String]): IO[ExitCode] = IO.pure(ExitCode.Success)
}
Blaze server
We have most of the pieces already in place, time to prepare and launch the HTTP server. This snippet will get us up and running:
def serverResource(routes: HttpRoutes[IO])(ec: ExecutionContext) =
BlazeServerBuilder[IO](ec)
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
Few more imports are necessary as well:
import scala.concurrent.ExecutionContext
import org.http4s.server.blaze.BlazeServerBuilder
import org.http4s.server.Router
import org.http4s.implicits._
Why do we need those? Let’s see:
ExecutionContext
think of it as an abstraction over thread pool, it will be used by our HTTP serverBlazeServerBuilder
builder for Blaze server, best described in http4s documentationRouter
maps server endpoint root toHttpRoutes[IO]
. In our particular case Tapir handles whole endpoint, so we use/
as base url.import org.http4s.implicits._
provides.orNotFound
syntax on routes (along many more useful implicits we don’t use in this example)
Are we done yet? Almost! we still need some logic in run
method. Here’s how we use our server:
override def run(args: List[String]): IO[ExitCode] =
serverResource(helloWorldRoutes)(executionContext)
.use(_ => IO.never)
.as(ExitCode.Success)
serverResource
returns a Resource
(see also Acquiring and releasing Resource
s). You can think of it as something that can be created, used and released.
In this example we want to create a HTTP server and use
it forever, making it run until we terminate the process with CTRL+C
. To achieve that, we need to provide a non-terminating IO
instance, thus we use IO.never
.
Now let’s execute run
in sbt shell, open another terminal window and test our application with curl
:
$ curl http://localhost:8080/hello\?name\=World
Hello, World!
It works! you can see the full program code below
package example
import cats.effect._
import sttp.tapir._
import sttp.tapir.server.http4s.Http4sServerInterpreter
import org.http4s.HttpRoutes
import cats.syntax.all._
import scala.concurrent.ExecutionContext
import org.http4s.server.blaze.BlazeServerBuilder
import org.http4s.server.Router
import org.http4s.syntax.kleisli._
object Hello extends IOApp {
val helloWorld: Endpoint[String, Unit, String, Any] =
endpoint
.get
.in("hello")
.in(query[String]("name"))
.out(stringBody)
def endpointLogic(name: String) = IO(s"Hello, $name!".asRight[Unit])
val helloWorldRoutes: HttpRoutes[IO] =
Http4sServerInterpreter.toRoutes(helloWorld)(endpointLogic _)
def serverResource(routes: HttpRoutes[IO])(ec: ExecutionContext) =
BlazeServerBuilder[IO](ec)
.bindHttp(8080, "localhost")
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
override def run(args: List[String]): IO[ExitCode] =
serverResource(helloWorldRoutes)(executionContext)
.use(_ => IO.never)
.as(ExitCode.Success)
}
Working example code can also be found in this commit.
Summary
In this part we have learned how to bootstrap a Scala application and set up a simple HTTP server using Tapir and Http4s.
In the next part we’ll extend our application with OAuth2 login capability using GitHub.