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:


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 project
  • project/Dependencies.scala defines source code dependencies. Contents of this file might as well be placed directly in build.sbt. Later on we’ll justify why it makes sense here.
  • All files in src/main/scala - sources of our application, where example 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 server
  • BlazeServerBuilder builder for Blaze server, best described in http4s documentation
  • Router maps server endpoint root to HttpRoutes[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 Resources). 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.