'Migrating to Play Framework 3: Handling Changes from Akka to Apache Pekko' post illustration

Migrating to Play Framework 3: Handling Changes from Akka to Apache Pekko

avatar

Previously, Dmitriy Chuprina provided guidance on building a basic application demonstrating authentication and authorization processes utilizing JSON Web Token (JWT), but in September 2022, Lightbend Inc. changed the Akka license model from the Apache 2.0 license to the Business Source License (BSL) 1.1 and now if you want to use the latest versions of Akka in a Play 2.x application in a way not described in the official Play Framework documentation, you may already need to obtain a license. As a result, in October 2023, a new version of Play Framework 3 was released, which uses Apache Pekko instead of Akka and Akka HTTP. So here we will analyze how to migrate a project to Play Framework 3.

Technologies:

  • Scala 2.13
  • Play Framework 3.0.1
  • Silhouette 10.0.0
  • Slick 3.4.1
  • PostgreSQL
  • Guice
  • Play evolutions

User model:

Data Model Schema

Dependencies used:

build.sbt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
name := "auth-with-play-silhouette-example"

version := "1.0-SNAPSHOT"

scalaVersion := "2.13.12"

resolvers += Resolver.jcenterRepo

resolvers += "Sonatype snapshots" at "https://oss.sonatype.org/content/repositories/snapshots/"

val playSilhouetteVersion = "10.0.0"
val slickVersion = "3.4.1"
val playSlickVersion = "6.0.0"

libraryDependencies ++= Seq(
  "org.playframework.silhouette" %% "play-silhouette" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-password-bcrypt" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-persistence" % playSilhouetteVersion,
  "org.playframework.silhouette" %% "play-silhouette-crypto-jca" % playSilhouetteVersion,
  "net.codingwell" %% "scala-guice" % "6.0.0",
  "com.typesafe.slick" %% "slick" % slickVersion,
  "com.typesafe.slick" %% "slick-hikaricp" % slickVersion,
  "org.playframework" %% "play-slick" % playSlickVersion,
  "org.playframework" %% "play-slick-evolutions" % playSlickVersion,
  //it's org.postgresql.ds.PGSimpleDataSource dependency
  "org.postgresql" % "postgresql" % "42.7.1",
  guice,
  filters
)

lazy val root = (project in file(".")).enablePlugins(PlayScala)

project/plugins.sbt

1
2
3
4
5
6
7
8
9
logLevel := Level.Warn

addSbtPlugin("org.playframework" % "sbt-plugin" % "3.0.0")

addSbtPlugin("org.scalariform" % "sbt-scalariform" % "1.8.3")

ThisBuild / libraryDependencySchemes ++= Seq(
 "org.scala-lang.modules" %% "scala-xml" % VersionScheme.Always
)

In project/build.properties, you also need to replace the sbt version with sbt.version=1.9.6. Newest versions of Play (2.9 / 3.0) only supports sbt 1.9 or newer.

In the conf/db.conf file, you need to set the database properties for Slick as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
slick {
   dbs {
       default {
           profile="slick.jdbc.PostgresProfile$"
           driver="slick.driver.PostgresDriver$"

           db {
               driver="org.postgresql.Driver"
               url="jdbc:postgresql://localhost:5432/your_database_name"
               user="your_user"
               password="your_password"
           }
       }
   }
}

Also, in the same file, set the settings for evolutions, which will enable evolutions by default in your application.

1
2
3
4
play.evolutions {
   enabled=true
   db.default.schema ="public"
}

In order to disable the implementation of our own error handling logic for secured and unsecured actions, disable the following modules SecuredErrorHandlerModule, UnsecuredErrorHandlerModule as follows

conf/application.conf

1
2
play.modules.disabled += "play.silhouette.api.actions.SecuredErrorHandlerModule"
play.modules.disabled += "play.silhouette.api.actions.UnsecuredErrorHandlerModule"

Given the inability to utilize joda.dataTime, we aim to leverage 'apache pekko' (org.apache.pekko.http.javadsl.model.DateTime). Our objective involves appending two fresh fields, dateOfCreation and dateOfCreation, to the custom object. Hence, we're tasked with crafting a Reader to interpret DateTime and LocalDate objects from JSON strings.

models/DTReader

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
trait DTReader {
 implicit val dateTimeReader: Reads[DateTime] = Reads { json =>
   json.validate[String] map { str =>
     DateTime.fromIsoDateTimeString(str).orElseThrow(() => new Exception("Invalid date format"))
   }
 }
 implicit val localDateReader: Reads[LocalDate] = Reads { json =>
   json.validate[String].flatMap { str =>
     LocalDate.parse(str, DateTimeFormatter.ISO_DATE) match {
       case localDate: LocalDate => JsSuccess(localDate)
       case _ => throw new Exception("invalid date")
     }
   }
 }
}

If we want to use the User instance as an entity to be authenticated, the user model must extend the Identity property in Silhouette.

model/User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
case class User(
                id: Option[Long],
                email: String,
                name: String,
                lastName: String,
                password: Option[String] = None,
                dateOfBirth: LocalDate,
                dateOfCreation: Option[DateTime]) extends Identity {

 /**
  * Generates login info from email
  *
  * @return login info
  */
 def loginInfo = LoginInfo(CredentialsProvider.ID, email)

 /**
  * Generates password info from password.
  *
  * @return password info
  */
 def passwordInfo = PasswordInfo(BCryptSha256PasswordHasher.ID, password.get)
}

We define implicit JSON readers and writers for the User class, inheriting features from the DTReader for processing DateTime and LocalDate conversions for serializing and deserializing JSON.

model/User

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
object User extends DTReader {

 import play.api.libs.json._

 implicit val userReads: Reads[User] = Json.reads[User]
 implicit val creatureWrites: Writes[User] = new Writes[User] {
   def writes(c: User): JsValue = Json.obj(
     "name" -> c.name,
     "lastName" -> c.lastName,
     "dateOfBirth" -> c.dateOfBirth.toString(),
     "email" -> c.email,
     "dateOfCreation" -> c.dateOfCreation.get.toString
   )
 }
}

Let's create a DTCType for implicit conversion between Scala types (LocalDate and DateTime) and their corresponding SQL types (java.sql.Date and java.sql.Timestamp).

tables/DTCType

1
2
3
4
5
6
7
8
9
10
11
12
13
trait DTCType {
 implicit val localDateColumnType: BaseColumnType[LocalDate] =
   MappedColumnType.base[LocalDate, java.sql.Date](
     ld => java.sql.Date.valueOf(ld),
     sqlDate => sqlDate.toLocalDate
   )

 implicit val dateTimeColumnType: JdbcType[DateTime] with BaseTypedType[DateTime] =
   MappedColumnType.base[DateTime, java.sql.Timestamp](
     dt => new java.sql.Timestamp(dt.clicks()),
     ts => DateTime.create(ts.getTime)
   )
}

Now, let's create a Slick table mapping for the User entity. This table mapping includes the DTCType attribute, which provides implicit conversion of column types for the LocalDate and DateTime types.

tables/UserTable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class UserTable(tag: Tag) extends Table[User](tag, Some("play_silhouette"), "users") with DTCType {

 /** The ID column, which is the primary key, and auto incremented */
 def id = column[Option[Long]]("id", O.PrimaryKey, O.AutoInc, O.Unique)

 /** The name column */
 def name = column[String]("name")

 /** The email column */
 def email = column[String]("email", O.Unique)

 /** The last name column */
 def lastName = column[String]("lastName")

 /** The password column */
 def password = column[Option[String]]("password")

 def dateOfBirth = column[LocalDate]("dateOfBirth")(localDateColumnType)

 def dateOfCreation = column[DateTime]("dateOfCreation")(dateTimeColumnType)

 /**
  * This is the table's default "projection".
  *
  * It defines how the columns are converted to and from the User object.
  *
  * In this case, we are simply passing the id, name, email and password parameters to the User case classes
  * apply and unapply methods.
  */
 def * = (id, email, name, lastName, password, dateOfBirth, dateOfCreation.?).<> ((User.apply _).tupled, User.unapply)
}

The * method defines the default table projection, specifying how columns are mapped to the User case class. It uses the <> operator to map columns to the apply and unapply methods of the User case class, which allows Slick to convert database rows to User objects and vice versa.

The UserService trait appears to define an abstract service for managing user-related operations, extending IdentityService[User], which is used in authentication and authorization contexts in Play Framework with Silhouette.

services/UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
trait UserService extends IdentityService[User] {

 /**
  * Saves a user.
  *
  * @param user The user to save.
  * @return The saved user.
  */
 def save(user: User): Future[User]

 /**
  * Updates a user.
  *
  * @param user The user to update.
  * @return The updated user.
  */
 def update(user: User): Future[User]
}

Let's develop an abstraction for a data access object (DAO) responsible for processing operations related to user persistence. This feature will describe methods for retrieving, saving, and updating user data.

services/UserService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
trait UserDAO {

 /**
  * Finds a user by its login info.
  *
  * @param loginInfo The login info of the user to find.
  * @return The found user or None if no user for the given login info could be found.
  */
 def find(loginInfo: LoginInfo): Future[Option[User]]

 /**
  * Saves a user.
  *
  * @param user The user to save.
  * @return The saved user.
  */
 def save(user: User): Future[User]

 /**
  * Updates a user.
  *
  * @param user The user to update.
  * @return The saved user.
  */
 def update(user: User): Future[User]
}

Let's create an implementation of the DelegableAuthInfoDAO[PasswordInfo] property that manages the storage and retrieval of password information (PasswordInfo) associated with user authentication.

daos/PasswordInfoImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class PasswordInfoImpl @Inject() (userDAO: UserDAO)(implicit val classTag: ClassTag[PasswordInfo], ec: ExecutionContext)
 extends DelegableAuthInfoDAO[PasswordInfo] {

 /**
  * Finds passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @return user's hashed password
  */
 override def find(loginInfo: LoginInfo): Future[Option[PasswordInfo]] = userDAO.find(loginInfo).map(_.map(_.passwordInfo))

 /**
  * Adds new passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def add(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = update(loginInfo, passwordInfo)

 /**
  * Updates passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def update(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = userDAO.find(loginInfo).flatMap {
   case Some(user) => userDAO.update(user.copy(password = Some(passwordInfo.password))).map(_.passwordInfo)
   case None => Future.failed(new Exception("user not found"))
 }

 /**
  * Adds new passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  * @param passwordInfo user's hashed password
  */
 override def save(loginInfo: LoginInfo, passwordInfo: PasswordInfo): Future[PasswordInfo] = update(loginInfo, passwordInfo)

 /**
  * Removes passwordInfo for specified loginInfo
  *
  * @param loginInfo user's email
  */
 override def remove(loginInfo: LoginInfo): Future[Unit] = update(loginInfo, PasswordInfo("", "")).map(_ => ())
}

Due to our requirement for sending a JSON object containing an error code and a descriptive message in the event of Unauthorized and Forbidden errors, we have disabled the default modules - SecuredErrorHandlerModule and UnsecuredErrorHandlerModule - at the project's outset. Now, we aim to implement our custom logic to handle these error scenarios.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class CustomSecuredErrorHandler @Inject() (val messagesApi: MessagesApi) extends SecuredErrorHandler with I18nSupport {
 /**
  * Called when a user is not authenticated.
  *
  * As defined by RFC 2616, the status code of the response should be 401 Unauthorized.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthenticated(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 401,
     "message" -> "Authentication failed. Given policy has not granted."
   )
   Future.successful(Unauthorized(jsonResponse))
 }

 /**
  * Called when a user is authenticated but not authorized.
  *
  * As defined by RFC 2616, the status code of the response should be 403 Forbidden.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthorized(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 403,
     "message" -> "User is authenticated but not authorized."
   )
   Future.successful(Forbidden(jsonResponse))
 }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class CustomUnsecuredErrorHandler extends UnsecuredErrorHandler {

 /**
  * Called when a user is authenticated but not authorized.
  *
  * As defined by RFC 2616, the status code of the response should be 403 Forbidden.
  *
  * @param request The request header.
  * @return The result to send to the client.
  */
 override def onNotAuthorized(implicit request: RequestHeader) = {
   val jsonResponse = Json.obj(
     "code" -> 403,
     "message" -> "User is authenticated but not authorized."
   )
   Future.successful(Forbidden(jsonResponse))
 }
}

Developing SilhouetteModule

SilhouetteModule is a Guice module that configures the dependencies associated with the Silhouette library in the Play Framework application to handle authentication and authorization. Let's set up bindings and configurations for dependency injection.

1
2
3
4
5
6
7
8
override def configure(): Unit = {
 bind[Silhouette[JWTEnvironment]].to[SilhouetteProvider[JWTEnvironment]]
 bind[UnsecuredErrorHandler].to[CustomUnsecuredErrorHandler]
 bind[SecuredErrorHandler].to[CustomSecuredErrorHandler]
 bind[IDGenerator].toInstance(new SecureRandomIDGenerator())
 bind[EventBus].toInstance(EventBus())
 bind[Clock].toInstance(Clock())
}

This method is a Guice provider method within the SilhouetteModule responsible for creating an instance of Environment[JWTEnvironment] used in the Silhouette library for handling authentication.

1
2
3
4
5
6
7
8
9
10
11
12
13
@Provides
def provideEnvironment(
 userService: UserService,
 authenticatorService: AuthenticatorService[JWTAuthenticator],
 eventBus: EventBus): Environment[JWTEnvironment] = {

 Environment[JWTEnvironment](
   userService,
   authenticatorService,
   Seq(),
   eventBus
 )
}

Method provideAuthenticatorCrypter is responsible for creating an instance of the Crypter interface that handles encryption and decryption tasks associated with authentication tokens or sensitive information used in the Silhouette library.

1
2
3
4
@Provides
def provideAuthenticatorCrypter(configuration: Configuration): Crypter = {
 new JcaCrypter(JcaCrypterSettings(configuration.underlying.getString("play.http.secret.key")))
}

This provideAuthenticatorService method is a Guice provider method within the SilhouetteModule. Its purpose is to create and provide an instance of AuthenticatorService[JWTAuthenticator] used in the Silhouette library for managing JWT-based authentication

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Provides
def provideAuthenticatorService(
 crypter: Crypter,
 idGenerator: IDGenerator,
 configuration: Configuration,
 clock: Clock): AuthenticatorService[JWTAuthenticator] = {

 val encoder = new CrypterAuthenticatorEncoder(crypter)
 new JWTAuthenticatorService(JWTAuthenticatorSettings(
   fieldName = configuration.underlying.getString("silhouette.authenticator.headerName"),
   issuerClaim = configuration.underlying.getString("silhouette.authenticator.issuerClaim"),
   authenticatorExpiry = Duration(configuration.underlying.getString("silhouette.authenticator.authenticatorExpiry")).asInstanceOf[FiniteDuration],
   sharedSecret = configuration.underlying.getString("silhouette.authenticator.sharedSecret")

 ), None, encoder, idGenerator, clock)
}

The providePasswordDAO purpose is to create and provide an instance of DelegableAuthInfoDAO[PasswordInfo] used in the Silhouette library to handle password-related authentication information.

1
2
@Provides
def providePasswordDAO(userDao: UserDAO): DelegableAuthInfoDAO[PasswordInfo] = new PasswordInfoImpl(userDao)

The provideCredentialsProvider method is to create and provide an instance of the CredentialsProvider used in the Silhouette library for authenticating users via credentials (e.g., username/password).

1
2
3
4
5
6
7
@Provides
def provideCredentialsProvider(
 authInfoRepository: AuthInfoRepository,
 passwordHasherRegistry: PasswordHasherRegistry): CredentialsProvider = {

 new CredentialsProvider(authInfoRepository, passwordHasherRegistry)
}

SilhouetteController

By defining an abstract Silhouette controller, you can encapsulate the setup and access to Silhouette-related components, such as SecuredAction, UnsecuredAction, userService, authInfoRepository, and others. This abstract controller serves as a blueprint, allowing other controllers to inherit these Silhouette-specific functionalities without the need to duplicate code or redefine these components in every controller.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
abstract class SilhouetteController(override protected val controllerComponents: SilhouetteControllerComponents)
        extends MessagesAbstractController(controllerComponents) with SilhouetteComponents with I18nSupport with Logging {

  def SecuredAction: SecuredActionBuilder[EnvType, AnyContent] = controllerComponents.silhouette.SecuredAction
  def UnsecuredAction: UnsecuredActionBuilder[EnvType, AnyContent] = controllerComponents.silhouette.UnsecuredAction

  def userService: UserService = controllerComponents.userService
  def authInfoRepository: AuthInfoRepository = controllerComponents.authInfoRepository
  def passwordHasherRegistry: PasswordHasherRegistry = controllerComponents.passwordHasherRegistry
  def clock: Clock = controllerComponents.clock
  def credentialsProvider: CredentialsProvider = controllerComponents.credentialsProvider

  def silhouette: Silhouette[EnvType] = controllerComponents.silhouette
  def authenticatorService: AuthenticatorService[AuthType] = silhouette.env.authenticatorService
  def eventBus: EventBus = silhouette.env.eventBus

Let's also update the SignUpController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class SignUpController @Inject() (
 components: SilhouetteControllerComponents
)(implicit ex: ExecutionContext) extends SilhouetteController(components) {

 /**
  * Handles sign up request
  *
  * @return The result to display.
  */
 def signUp = UnsecuredAction.async { implicit request: Request[AnyContent] =>
   implicit val lang: Lang = supportedLangs.availables.head
   request.body.asJson.flatMap(_.asOpt[User]) match {
     case Some(newUser) if newUser.password.isDefined =>
       userService.retrieve(LoginInfo(CredentialsProvider.ID, newUser.email)).flatMap {
         case Some(_) =>
           Future.successful(Conflict(JsString(messagesApi("user.already.exist"))))
         case None =>
           val authInfo = passwordHasherRegistry.current.hash(newUser.password.get)
           val user = newUser.copy(password = Some(authInfo.password), dateOfCreation = Option(DateTime.now))
           userService.save(user).map(u => Ok(Json.toJson(u.copy(password = None))))
       }
     case _ => Future.successful(BadRequest(JsString(messagesApi("invalid.body"))))
   }
 }
}

No changes are needed for the remaining modules as they function appropriately without any adjustments.

Release of the Play Framework 3 is a significant milestone in the framework's development as a leading OSS Web Framework for the Scala ecosystem. A public Akka fork, Apache Pekko, is adopted as its core asynchronous event system instead of Akka and Akka HTTP. This migration brings a simpler licensing model. However, it also necessitates some changes to existing applications, especially those heavily reliant on Akka.

The complete source code of the example could be found here.

If you're looking for a developer or considering starting a new project,
we are always ready to help!