OAuth2 Scala application with sttp-oauth2
Building simple Scala web application with OAuth2 Github login - part 2
In the first part we have prepared a simple web application using Scala with Tapir and Http4s. It can be found in this commit. In this part we’ll integrate our application with Github, providing OAuth2 login and some basic API integration.
Before we get started make sure to register a Github application. This article will guide you through the process: https://docs.github.com/en/developers/apps/building-oauth-apps/creating-an-oauth-app
Authorization code grant
OAuth2 (described in rfc6749) defines four grant types: authorization code, implicit, resource owner password credentials, and client credentials. For authorizing end users using Github, we’ll use authorization code grant.
We won’t be discussing its details in this article, as it’s already described in detail in rfc6749 section 4.1. There’s also a great example in oauth.com playground.
In short, this is how the flow looks like from the application perspective, particularly when logging in using Github:
- User visits a login endpoint, say
/api/login-redirect
- Application generates a redirect URL with all required parameters and redirects the user
- User is redirected to Github login website, logs in and gets redirected back to our app
- Application exchanges received authorization code for user token
From now on we can use the received token with Github API.
Now that we are familiar with the general concept, we can start the implementation.
Implementation
Endpoints
sttp-oauth2 provides a convenient way of hiding the RFC details. It’s already included in our Dependencies.scala
file.
private val sttpOAuth2 = Seq(
"com.ocadotechnology" %% "sttp-oauth2" % Versions.SttpOAuth2
)
Let’s get started by defining two endpoints:
- Redirect endpoint:
GET /api/login-redirect
, takes no parameters, responds with303 See Other
response code and aLocation
header. - Post login endpoint:
GET /api/post-login
expects two parameterscode
representing authorization code, to be exchanged for token in endpoint logicstate
can later be used to improve security by introducing CSRF verification
Now let’s take those requirements and implement those endpoints using tapir.
Login endpoint can be implemented like this:
val loginRedirect: Endpoint[Unit, Unit, String, Any] =
endpoint
.get
.in("api" / "login-redirect")
.out(header[String]("Location"))
.out(statusCode(sttp.model.StatusCode.SeeOther))
Post login endpoint is also rather self explanatory and can be implemented following way:
val postLogin: Endpoint[(String, String), Unit, String, Any] =
endpoint
.get
.in("api" / "post-login")
.in(query[String]("code"))
.in(query[String]("state"))
.out(stringBody)
This is it, we already have our endpoints defined, but there’s still some space for improvement. Using simple types like String
or tuples like (String, String)
doesn’t help understanding the role of those types.
This can be improved by introducing value classes. Value class in Scala is a case class that wraps around another type without the need to allocate an extra object. It allows us to both add a specific name to primitive type, but also some additional logic (which is out of scope of this article), with no cost (with [some exceptions](some link here to point to exceptions)).
To improve our endpoint definitions, let’s introduce following value classes:
final case class AuthorizationCode(value: String) extends AnyVal
final case class State(value: String) extends AnyVal
final case class RedirectUrl(value: String) extends AnyVal
Our endpoints can be updated accordingly:
val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any] =
endpoint
.get
.in("api" / "login-redirect")
.out(header[RedirectUrl]("Location"))
.out(statusCode(sttp.model.StatusCode.SeeOther))
val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any] =
endpoint
.get
.in("api" / "post-login")
.in(query[AuthorizationCode]("code"))
.in(query[State]("state"))
.out(stringBody)
Is it enough? Let’s try compiling it!
[error] (REDACTED) Cannot find a codec between types: List[String] and example.OAuthEndpoints.RedirectUrl, formatted as: sttp.tapir.CodecFormat.TextPlain.
[error] Did you define a codec for: example.OAuthEndpoints.RedirectUrl?
[error] Did you import the codecs for: sttp.tapir.CodecFormat.TextPlain?
[error] .out(header[RedirectUrl]("Location"))
[error] ^
[error] (REDACTED) Cannot find a codec between types: List[String] and example.OAuthEndpoints.AuthorizationCode, formatted as: sttp.tapir.CodecFormat.TextPlain.
[error] Did you define a codec for: example.OAuthEndpoints.AuthorizationCode?
[error] Did you import the codecs for: sttp.tapir.CodecFormat.TextPlain?
[error] .in(query[AuthorizationCode]("code"))
[error]
We can see that we are missing some codecs. Why does it happen?
When tapir processes headers, path pieces or query parameters, it can only reason in terms of plain text strings. To let it know how to operate on our value classes, we need to provide relevant codecs in implicit scope. It’s best to provide those in companion objects of our value classes.
This is how we implement Tapir TextPlain
codec in Tapir:
final case class State(value: String) extends AnyVal
object State {
implicit val endpointCodec: Codec[String, State, TextPlain] =
Codec.string.map(State(_))(_.value)
}
As we can see, Codec.string
provides a map
method, that takes two functions. First one takes a String
argument and converts it into our value class. The second one does the opposite, changing our value class to String
. Alternatively we can use Codec.stringCodec(State(_))
that does the same trick.
With this knowledge, we can adapt our implementation. Our final endpoints implementation looks like this:
package example
import sttp.tapir._
import sttp.tapir.CodecFormat.TextPlain
object OAuthEndpoints {
val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any] =
endpoint
.get
.in("api" / "login-redirect")
.out(header[RedirectUrl]("Location"))
.out(statusCode(sttp.model.StatusCode.SeeOther))
val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any] =
endpoint
.get
.in("api" / "post-login")
.in(query[AuthorizationCode]("code"))
.in(query[State]("state"))
.out(stringBody)
final case class AuthorizationCode(value: String) extends AnyVal
object AuthorizationCode {
implicit val endpointCodec: Codec[String, AuthorizationCode, TextPlain] =
Codec.string.map(AuthorizationCode(_))(_.value)
}
final case class State(value: String) extends AnyVal
object State {
implicit val endpointCodec: Codec[String, State, TextPlain] =
Codec.string.map(State(_))(_.value)
}
final case class RedirectUrl(value: String) extends AnyVal
object RedirectUrl {
implicit val endpointCodec: Codec[String, RedirectUrl, TextPlain] =
Codec.string.map(RedirectUrl(_))(_.value)
}
}
This version can also be found in the demo repository in this particular commit.
Router
Now that our endpoints are ready, we need to implement two methods the same way we did in part one of this article. Our methods can be gathered in a trait we’d call a Router like this:
import cats.effect.IO
import OAuthEndpoints._
trait OAuth2Router {
def loginRedirect: IO[RedirectUrl]
def handleLogin(code: AuthorizationCode, state: State): IO[String]
}
loginRedirect
corresponds with val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any]
and handleLogin
with val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any]
.
Now that we have decided how to shape our router, let’s implement it. Let’s create a companion object with instance
method:
import sttp.model.Uri
object OAuth2Router {
def instance(
authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO]
): OAuth2Router = new OAuth2Router {
override def loginRedirect: IO[RedirectUrl] = ???
override def handleLogin(code: AuthorizationCode, state: State): IO[String] = ???
}
}
Login redirect
Mind that our instance takes a parameter - implementation of AuthorizationCodeProvider
. We’ll use it for our implementation. Let’s start with loginRedirect
.
override def loginRedirect: IO[RedirectUrl] = {
val uri = authorizationCodeProvider.loginLink()
val redirectUrl = RedirectUrl(uri.toString())
IO.pure(redirectUrl)
}
We are performing a pure computation - authorizationCodeProvider
generates a link, we wrap it in desired data structure and then wrap it with IO
monad to satisfy the interface. We do wrap in IO
already as Http4sServerInterpreter.toRoutes
expects the result to be wrapped in IO anyway.
Login handling
Now to the more complex part. The implementation for handleLogin
should:
- Use the authorization code to retrieve user token
- Use token to request user info from Github API
- Return user name from received response
Before continuing with handleLogin
, let’s have a second to think about the Github API. To receive authenticated user details we’ll use https://api.github.com/user
. The documentation for this endpoint describes the full data structure, but to make things easier we’ll only use two fields: name
and login
. Here’s how we’d model this subset of API response:
object Github {
final case class UserInfo(
login: String,
name: String
)
}
We have the model, we still need to describe our API behavior. Let’s once again (as for OAuthRouter
) follow programming to an interface.
In this case we only need one endpoint, so our trait
would have single method:
trait Github {
def userInfo(accessToken: Secret[String]): IO[Github.UserInfo]
}
The Secret
type comes from sttp-oauth2, we import it with import com.ocadotechnology.sttp.oauth2.Secret
. It’s useful for working with data we don’t want to leak into logs. It provides custom toString
method, that returns obfuscated content instead of the original one.
We’ll leave the implementation for now. Let’s get back to OAuthRouter
and implement handleLogin
using this API.
The first thing we need to change is the instance
method’s signature, as it now takes Github
instance as another parameter:
def instance(
authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO],
github: Github
)
Now that this API is available in scope, we can finally implement handleLogin
:
override def handleLogin(code: AuthorizationCode, state: State): IO[String] =
for {
token <- authorizationCodeProvider
.authCodeToToken[OAuth2TokenResponse](code.value)
userInfo <- github.userInfo(token.accessToken)
} yield s"Logged in as $userInfo"
Finally our complete implementation of OAuthRouter
looks like this:
package example
import cats.effect.IO
import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider
import com.ocadotechnology.sttp.oauth2.OAuth2TokenResponse
import sttp.model.Uri
import OAuthEndpoints._
trait OAuth2Router {
def loginRedirect: IO[RedirectUrl]
def handleLogin(code: AuthorizationCode, state: State): IO[String]
}
object OAuth2Router {
def instance(
authorizationCodeProvider: AuthorizationCodeProvider[Uri, IO],
github: Github
): OAuth2Router = new OAuth2Router {
override def loginRedirect: IO[RedirectUrl] = {
val uri = authorizationCodeProvider.loginLink()
val redirectUrl = RedirectUrl(uri.toString())
IO.pure(redirectUrl)
}
override def handleLogin(code: AuthorizationCode, state: State): IO[String] =
for {
token <- authorizationCodeProvider
.authCodeToToken[OAuth2TokenResponse](code.value)
userInfo <- github.userInfo(token.accessToken)
} yield s"Logged in as $userInfo"
}
}
Github API implementation
Our application is not complete without Github implementation. To do that we need HTTP client, we’ll use sttp library.
Let’s plan our work, here’s what we need our API implementation to do:
- Define request matching the user info endpoint documentation. Remember about
- Using correct request method
- Attaching authorization header
- Send the response using provided
SttpBackend
instance - Decode the received JSON into
Github.UserInfo
case class
Yet another new term - decoding JSON into case classes. This functionality is provided by circe
. We won’t be going to deep into the details, as this is a subject for another article. For our use case it’s enough to rely on automatic codec derivation. This means that circe, at compile time, will generate implementations of Codec
for every case class in the file where derivation is imported. In case it’s not enough for your use case, refer to the documentation for writing custom codecs.
Providing circe codecs is enough in our case, because sttp provides circe integration.
Now that we have the theory explained, let’s get back to coding. To make the automatic derivation happen, we need a handful of imports first:
import sttp.client3._ // sttp API
import sttp.client3.circe._ // sttp circe integration
import io.circe.generic.auto._ // automatic derivation of circe codecs for case classes
Now in the body of Github
object, let’s implement instance according to our plan.
val baseUri = uri"https://api.github.com/"
def instance(backend: SttpBackend[IO, Any]) = new Github {
override def userInfo(accessToken: Secret[String]): IO[UserInfo] = {
val header = Header("Authorization", s"Bearer ${accessToken.value}")
basicRequest
.get(baseUri.withPath("user"))
.headers(header)
.response(asJson[UserInfo])
.send(backend)
.map(_.body)
.rethrow
}
}
We have started with defining baseUri
for Github API. Then the instance
method, taking SttpBackend
(parameterized by effect type IO
and no particular capabilities - thus Any
).
User info follows sttp request definition. We first define header content for authorization. Then next four lines describe GET
request to https://api.github.com/user
endpoint, attaching our header. response(asJson[UserInfo])
tells us a lot - we declare the that we expect the endpoint to return Content-Type: application/json
that can be deserialized to UserInfo
case class. Quite expressive huh?
Then comes the send(backend)
method. It uses the backend to perform the request. At this point we are working with IO[Response]
type. Then we map the resulting IO
to contain just the body
- we don’t care about other things like response headers.
The last thing is rethrow
(available through cats.implicits._
). It basically converts IO[Either[ResponseException, UserInfo]]
to IO[UserInfo]
. In case the inner Either
was Left
, the IO
will be failed.
To make sure everything compiles, make sure to include following imports at the top of your file.
import sttp.model.Header
import cats.implicits._
Our final contents of Github.scala
package example
import cats.effect.IO
import cats.implicits._
import com.ocadotechnology.sttp.oauth2.Secret
import io.circe.generic.auto._
import sttp.client3._
import sttp.client3.circe._
import sttp.model.Header
trait Github {
def userInfo(accessToken: Secret[String]): IO[Github.UserInfo]
}
object Github {
final case class UserInfo(
login: String,
name: String
)
val baseUri = uri"https://api.github.com/"
def instance(backend: SttpBackend[IO, Any]) = new Github {
override def userInfo(accessToken: Secret[String]): IO[UserInfo] = {
val header = Header("Authorization", s"Bearer ${accessToken.value}")
basicRequest
.get(baseUri.withPath("user"))
.headers(header)
.response(asJson[UserInfo])
.send(backend)
.map(_.body)
.rethrow
}
}
}
This part is also summarized in this commit in the demo repository.
Connecting the dots
At this point we have all the building blocks in place. Let’s make our app use the code we’ve just written. To make our app work, we’ll need to:
- Create an instance of
SttpBackend
- we’ll use cats-effect backend for that - Instantiate AuthorizationCodeProvider with the grant, configured with our github application details (obtained in github app creation process)
- Make an instance of our
Github
API - Create
OAuth2Router
, createHttpRoutes
by connecting the router logic with endpoint definitions - Expose our routes in HTTP server
Before we get started, let’s make one single fix. Our main application is still called Hello.scala
. Let’s rename the file to Main.scala
and rename the object Hello
to object Main
.
SttpBackend
In the previous article we have already defined all necessary dependencies. Following import: "com.softwaremill.sttp.client3" %% "async-http-client-backend-cats-ce2"
provides the backend implementation for us. We’ll use cats-effect backend, as we have built our app on top of cats effect stack. There are plenty more backends available, depending on your use case. Please refer to sttp documentation to find them.
We’ll follow the documentation for cats-effect backend. In our Main.scala
file, we need to add an import: import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend
. With it, instantiating the backend is as simple as calling AsyncHttpClientCatsBackend[IO]()
. This constructor returns IO[SttpBackend[IO, Any]]
, while our interfaces require SttpBackend[IO, Any]
. To unwrap it, we need to flatMap
the outer IO
instance.
To start with, we could go with something like this:
override def run(args: List[String]): IO[ExitCode] =
AsyncHttpClientCatsBackend[IO]().flatMap { sttpBackend =>
// Some logic for creating AuthorizationCodeProvider and Github instances
serverResource(helloWorldRoutes)(executionContext).use(_ => IO.never)
}.as(ExitCode.Success)
This approach doesn’t scale well in case we have more IO
wrapped values to unwrap. We can use the for
comprehension to make it look nicer and scale better.
It would look somewhat like this:
override def run(args: List[String]): IO[ExitCode] = for {
sttpBackend <- AsyncHttpClientCatsBackend[IO]()
appConfig <- ???
authorizationCodeProvider = ???
github = ???
oAuth2Router = ???
routes = ???
_ <- serverResource(helloWorldRoutes)(executionContext)
.use(_ => IO.never)
} yield ExitCode.Success
Now that we have the skeleton prepared, let’s fill the gaps. We have left helloWorldRoutes
in the server resource initialization just to make the example compile. We’ll replace it with routes
later on.
You probably noticed the appConfig
part. Our application needs to read application ID and secret, we’d also like to be able to configure listening host and port, thus let’s write a simple configuration reader.
Configuration
As discussed above, our application could use some configuration. There are two areas we definitely would like to configure. First of those is the http server host details. It’s nothing sophisticated, we only need a host
and port
, something we can model like this:
final case class Server(
host: String,
port: Int
)
Then there’s the second part, the OAuth2 application details. On the github side, we obtain appId
and appSecret
, and to let sttp-oauth2
know which identity provider to contact, we could also need some providerBaseUrl
. Overall this is how it could look like:
final case class OAuth2(
providerBaseUrl: Uri,
appId: String,
appSecret: String
)
Mind the usage of Uri
here, it’s sttp.model.Uri
, it makes sure we are not providing non-compliant string.
Overall this is how our Config.scala
file looks like
package example
import sttp.model.Uri
final case class Config(
oauth2: Config.OAuth2,
server: Config.Server
)
object Config {
final case class Server(
host: String,
port: Int
)
final case class OAuth2(
providerBaseUrl: Uri,
appId: String,
appSecret: String
)
}
Now that we have modeled the structure, let’s read the configuration from somewhere. To make things easier, we’ll only read the necessary details from environment variables.
We’ll start by creating ConfigReader.scala
file like this:
package example
import cats.effect.IO
object ConfigReader {
def read: IO[Config] = ???
}
If we want to read data from env variables, we’ll need some logic for that:
private def readFromEnv(variableName: String): IO[String] =
IO.delay(sys.env(variableName))
This uses scala’s builtin sys
package, that provides env
map. Accessing a map like that would normally make a risk of throwing an excaption, so using the IO.defer
makes perfect sense here, quoting the documentation:
Suspends a synchronous side effect in
IO
.Any exceptions thrown by the effect will be caught and sequenced into the
IO
.
Now let’s use this method to create Config.OAuth2
instance:
private def readOAuth2Config: IO[Config.OAuth2] = for {
id <- readFromEnv(appIdEnvVariable)
secret <- readFromEnv(appSecretEnvVariable)
url = Uri.unsafeParse("https://github.com/")
} yield Config.OAuth2(url, id, secret)
You can notice we are using unsafeParse
. If we were to parse arbitrary string from some input, we’d use Uri.parse
and handle the left hand side of the resulting Either
. In this case we are sure this is a valid string, as it’s hard coded, and we just want to simplify the implementation.
To make things even easier, let’s use static server configuration for the beginning
private val serverConfig = Config.Server("localhost", 8080)
You can rewrite it in terms of readFromEnv
on your own as you please.
Gathering it all together, we end up with following contents of ConfigReader.scala
:
package example
import cats.effect.IO
import cats.implicits._
import sttp.model.Uri
object ConfigReader {
val appIdEnvVariable = "APP_ID"
val appSecretEnvVariable = "APP_SECRET"
private def readFromEnv(variableName: String): IO[String] =
IO.delay(sys.env(variableName))
private def readOAuth2Config: IO[Config.OAuth2] = for {
id <- readFromEnv(appIdEnvVariable)
secret <- readFromEnv(appSecretEnvVariable)
url = Uri.unsafeParse("https://github.com/")
} yield Config.OAuth2(url, id, secret)
private val serverConfig = Config.Server("localhost", 8080)
def read: IO[Config] = for {
oAuth2Config <- readOAuth2Config
} yield Config(
oAuth2Config,
serverConfig
)
}
Our simple and elegant reader is ready. Let’s go straight back to Main.scala
and use it. There are two changes to be made:
- config reader should be used instead of
???
inrun
method serverResource
method should use provided server config for host and port
The updated code looks like this:
def serverResource(httpConfig: Config.Server, routes: HttpRoutes[IO])(ec: ExecutionContext) =
BlazeServerBuilder[IO](ec)
.bindHttp(httpConfig.port, httpConfig.host)
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
override def run(args: List[String]): IO[ExitCode] = for {
sttpBackend <- AsyncHttpClientCatsBackend[IO]()
appConfig <- ConfigReader.read
authorizationCodeProvider = ???
github = ???
oAuth2Router = ???
routes = ???
_ <- serverResource(appConfig.server, helloWorldRoutes)(executionContext).use(_ => IO.never)
} yield ExitCode.Success
AuthorizationCodeProvider
The AuthorizationCodeProvider
provided by import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider
has 2 methods for creating the provider: uriInstance
and refinedInstance
. We’ll use AuthorizationCodeProvider.uriInstance
as it uses sttp.model.Uri
so it matches our model. Mind that this method takes the sttp backend implicitly, in the second parameter list.
In our run
method we already have SttpBackend
and Config
instances, so let’s define a method that uses those to create AuthorizationCodeProvider
:
def authorizationCodeProviderInstance(
config: Config
)(
implicit backend: SttpBackend[IO, Any]
): AuthorizationCodeProvider[Uri, IO] =
AuthorizationCodeProvider.uriInstance[IO](
baseUrl = config.oauth2.providerBaseUrl,
redirectUri = Uri.unsafeParse(s"http://${config.server.host}:${config.server.port}/api/post-login"),
clientId = config.oauth2.appId,
clientSecret = Secret(config.oauth2.appSecret),
pathsConfig = AuthorizationCodeProvider.Config.GitHub
)
To make it work we could use a handful of imports:
import sttp.client3.SttpBackend
import sttp.model.Uri
import com.ocadotechnology.sttp.oauth2.Secret
With that defined, let’s update our run
method, replacing ???
with proper implementation:
override def run(args: List[String]): IO[ExitCode] = for {
sttpBackend <- AsyncHttpClientCatsBackend[IO]()
appConfig <- ConfigReader.read
authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
github = ???
oAuth2Router = ???
routes = ???
_ <- serverResource(appConfig.server, helloWorldRoutes)(executionContext)
.use(_ => IO.never)
} yield ExitCode.Success
Exposing the routes
We’re almost done, with no further hesitation we can fill out two more gaps by simply calling instance
methods on Github
and OAuth2Router
, as we already have all the required parameters:
override def run(args: List[String]): IO[ExitCode] = for {
sttpBackend <- AsyncHttpClientCatsBackend[IO]()
appConfig <- ConfigReader.read
authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
github = Github.instance(sttpBackend)
oAuth2Router = OAuth2Router.instance(authorizationCodeProvider, github)
routes = ???
_ <- serverResource(appConfig.server, helloWorldRoutes)(executionContext)
.use(_ => IO.never)
} yield ExitCode.Success
The only thing left here is to implement the routes = ???
and use it instead of helloWorldRoutes
.
To start with, let’s change following import:
import sttp.tapir.server.http4s.Http4sServerInterpreter
to this one:
import sttp.tapir.server.http4s.Http4sServerInterpreter.toRoutes
Now we’ll implement our routes generator. Fasten your seat belt as this one might be tough.
def appRoutes(oAuth2Router: OAuth2Router): HttpRoutes[IO] =
List(
toRoutes(helloWorld)(endpointLogic _),
toRoutes(OAuthEndpoints.loginRedirect)(
_ => oAuth2Router.loginRedirect.map(_.asRight[Unit])
),
toRoutes(OAuthEndpoints.postLogin)(
(oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))
)
).reduceLeft(_ <+> _)
Now what happened here? Simplification with pseudo-code might be helpful
List(
httpRoute1,
httpRoute2,
httpRoute3
).reduceLeft(_ <+> _)
SemigroupK
Let’s digress for a moment and take baby steps.
To make things easier to understand, let’s imagine we have a list of Int
s we want to combine. We could do it like this:
scala> List(1,2,3).reduceLeft(_ + _)
val res1: Int = 6
In Cats we could go with more generic approach using Semigroup
:
import cats.implicits._
scala> import cats.Semigroup
import cats.Semigroup
scala> List(1,2,3).reduceLeft(Semigroup[Int].combine)
val res2: Int = 6
Simply speaking, Semigroup[A] over a type A
means that two values can be combined into another value of type A
. Please refer Cats documentation and Herding cats - Semigroup - those are the best explanations in my opinion.
Now that we have an intuition about Semigroup, how about combining a sequence of lists like this: Seq(List(1,2), List(3,4))
?
We cannot use Semigroup[A]
here, as it’s defined for simple type A
. We need something like Semigroup[F[_]]
that would work over generic types. Such thing exists and it’s called SeimgroupK[F[_]]
. Again, I won’t go into details as both cats documentation on SemigroupK and Herding cats - SemigroupK explain it perfectly already.
Here’s how we’d combine sequence of lists using SemigroupK
:
scala> Seq(List(1,2), List(3,4)).reduceLeft(_ <+> _)
val res3: List[Int] = List(1, 2, 3, 4)
The <+>
here is an alias for combineK
method in SemigroupK
. End of digression.
Http4s provides an instance of SemigroupK
for HttpRoute
(please refer to http4s documentation), thus we can use <+>
to combine routes.
Another complicated parts in the example above are
_ => oAuth2Router.loginRedirect.map(_.asRight[Unit])
and
(oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))
If you recall the first part of this article, the server interpreter expects a function that takes a tuple (I1, I2,...IN)
and converts it to Either[Error, Result]
wrapped in effect. With this in mind, considering the endpoint definitions we have:
val loginRedirect: Endpoint[Unit, Unit, RedirectUrl, Any]
So the interpreter expects a method of a signature:
Unit => IO[Either[Unit, RedirectUrl]]
And for postLogin
:
val postLogin: Endpoint[(AuthorizationCode, State), Unit, String, Any]
we need:
((AuthorizationCode, State)) => IO[Either[Unit, String]]
The code complication is there only to satisfy the expected types.
Launching the app 🚀
Our final Main.scala
file looks like this:
package example
import cats.effect._
import sttp.tapir._
import sttp.tapir.server.http4s.Http4sServerInterpreter.toRoutes
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._
import sttp.client3.asynchttpclient.cats.AsyncHttpClientCatsBackend
import com.ocadotechnology.sttp.oauth2.AuthorizationCodeProvider
import sttp.client3.SttpBackend
import sttp.model.Uri
import com.ocadotechnology.sttp.oauth2.Secret
object Main 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] =
toRoutes(helloWorld)(endpointLogic _)
def serverResource(httpConfig: Config.Server, routes: HttpRoutes[IO])(ec: ExecutionContext) =
BlazeServerBuilder[IO](ec)
.bindHttp(httpConfig.port, httpConfig.host)
.withHttpApp(Router("/" -> routes).orNotFound)
.resource
def authorizationCodeProviderInstance(
config: Config
)(
implicit backend: SttpBackend[IO, Any]
): AuthorizationCodeProvider[Uri, IO] =
AuthorizationCodeProvider.uriInstance[IO](
baseUrl = config.oauth2.providerBaseUrl,
redirectUri = Uri.unsafeParse(s"http://${config.server.host}:${config.server.port}/api/post-login"),
clientId = config.oauth2.appId,
clientSecret = Secret(config.oauth2.appSecret),
pathsConfig = AuthorizationCodeProvider.Config.GitHub
)
def appRoutes(oAuth2Router: OAuth2Router): HttpRoutes[IO] =
List(
toRoutes(helloWorld)(endpointLogic _),
toRoutes(OAuthEndpoints.loginRedirect)(_ => oAuth2Router.loginRedirect.map(_.asRight[Unit])),
toRoutes(OAuthEndpoints.postLogin)(
(oAuth2Router.handleLogin _).tupled.andThen(_.map(_.asRight[Unit]))
)
).reduceLeft(_ <+> _)
override def run(args: List[String]): IO[ExitCode] = for {
sttpBackend <- AsyncHttpClientCatsBackend[IO]()
appConfig <- ConfigReader.read
authorizationCodeProvider = authorizationCodeProviderInstance(appConfig)(sttpBackend)
github = Github.instance(sttpBackend)
oAuth2Router = OAuth2Router.instance(authorizationCodeProvider, github)
routes = appRoutes(oAuth2Router)
_ <- serverResource(appConfig.server, routes)(executionContext).use(_ => IO.never)
} yield ExitCode.Success
}
It had a little clean up as val helloWorldRoutes
was no longer necessary. The code is also available in this commit on Github.
To launch the application, run following command in the root of your repository:
APP_ID="..." APP_SECRET="..." sbt
Fill the dots with the details from app registration process. Type run
when prompted by the sbt
console.
In your browser, navigate to https://localhost:8080/api/login-redirect and you should be redirected to login screen:
After logging in, you’ll be redirected to https://localhost:8080/api/post-login and you should see something like this:
Summary
In this part we have learned how to scale our application with new functionalities. We have managed to use libraries like sttp, circe and sttp-auth2 to build web application and enable login with Github.
Hope you enjoyed this part, feel free to contact me in case of any questions and stay tuned, there’s more to come!