Web Development: Play Framework and Http4s
Web development in Scala offers powerful frameworks that leverage the language's strengths for building scalable, maintainable web applications. In this lesson, we'll explore two leading frameworks: Play Framework for traditional MVC applications and Http4s for purely functional web services.
Understanding Scala Web Frameworks
Play Framework
- Full-stack MVC framework
- Built on Akka and Akka HTTP
- Hot reloading in development
- Integrated testing framework
- Strong ecosystem integration
Http4s
- Purely functional HTTP library
- Built on Cats Effect
- Composable and type-safe
- Streaming-first approach
- Minimal and modular
Play Framework: Full-Stack Web Development
Play Framework provides a complete solution for web application development with a focus on developer productivity and type safety.
Setting Up Play Framework
// project/plugins.sbt
addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.19")
// build.sbt
lazy val root = (project in file("."))
.enablePlugins(PlayScala)
.settings(
name := "play-app",
version := "1.0.0",
scalaVersion := "2.13.12",
libraryDependencies ++= Seq(
guice,
"org.scalatestplus.play" %% "scalatestplus-play" % "5.1.0" % Test,
"com.typesafe.play" %% "play-slick" % "5.1.0",
"com.typesafe.play" %% "play-slick-evolutions" % "5.1.0",
"com.h2database" % "h2" % "2.2.224",
"org.postgresql" % "postgresql" % "42.6.0"
)
)
Basic Play Application Structure
// app/controllers/HomeController.scala
package controllers
import javax.inject._
import play.api._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class HomeController @Inject()(
val controllerComponents: ControllerComponents
)(implicit ec: ExecutionContext) extends BaseController {
def index() = Action { implicit request: Request[AnyContent] =>
Ok(views.html.index())
}
def api() = Action {
Ok(Json.obj(
"message" -> "Hello from Play API",
"timestamp" -> System.currentTimeMillis()
))
}
}
Models and Database Integration
// app/models/User.scala
package models
import play.api.libs.json._
case class User(
id: Option[Long] = None,
name: String,
email: String,
age: Int
)
object User {
implicit val userFormat: Format[User] = Json.format[User]
}
// app/models/UserRepository.scala
package models
import javax.inject.{Inject, Singleton}
import play.api.db.slick.DatabaseConfigProvider
import slick.jdbc.JdbcProfile
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class UserRepository @Inject()(
dbConfigProvider: DatabaseConfigProvider
)(implicit ec: ExecutionContext) {
private val dbConfig = dbConfigProvider.get[JdbcProfile]
import dbConfig._
import profile.api._
private class UserTable(tag: Tag) extends Table[User](tag, "users") {
def id = column[Long]("id", O.PrimaryKey, O.AutoInc)
def name = column[String]("name")
def email = column[String]("email")
def age = column[Int]("age")
def * = (id.?, name, email, age) <> (User.tupled, User.unapply)
}
private val users = TableQuery[UserTable]
def create(user: User): Future[User] = {
val insertQuery = users returning users.map(_.id) into ((user, id) => user.copy(id = Some(id)))
db.run(insertQuery += user)
}
def list(): Future[Seq[User]] = db.run(users.result)
def findById(id: Long): Future[Option[User]] = {
db.run(users.filter(_.id === id).result.headOption)
}
def update(id: Long, user: User): Future[Option[User]] = {
val updateQuery = users.filter(_.id === id).update(user.copy(id = Some(id)))
db.run(updateQuery).map {
case 0 => None
case _ => Some(user.copy(id = Some(id)))
}
}
def delete(id: Long): Future[Boolean] = {
db.run(users.filter(_.id === id).delete).map(_ > 0)
}
}
RESTful API Controller
// app/controllers/UserController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import models.{User, UserRepository}
import scala.concurrent.{ExecutionContext, Future}
@Singleton
class UserController @Inject()(
cc: ControllerComponents,
userRepository: UserRepository
)(implicit ec: ExecutionContext) extends AbstractController(cc) {
def list = Action.async { implicit request =>
userRepository.list().map { users =>
Ok(Json.toJson(users))
}
}
def get(id: Long) = Action.async { implicit request =>
userRepository.findById(id).map {
case Some(user) => Ok(Json.toJson(user))
case None => NotFound(Json.obj("error" -> s"User with id $id not found"))
}
}
def create = Action.async(parse.json) { implicit request =>
request.body.validate[User].fold(
errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
user => {
userRepository.create(user).map { createdUser =>
Created(Json.toJson(createdUser))
}
}
)
}
def update(id: Long) = Action.async(parse.json) { implicit request =>
request.body.validate[User].fold(
errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
user => {
userRepository.update(id, user).map {
case Some(updatedUser) => Ok(Json.toJson(updatedUser))
case None => NotFound(Json.obj("error" -> s"User with id $id not found"))
}
}
)
}
def delete(id: Long) = Action.async { implicit request =>
userRepository.delete(id).map { deleted =>
if (deleted) Ok(Json.obj("message" -> "User deleted successfully"))
else NotFound(Json.obj("error" -> s"User with id $id not found"))
}
}
}
Routing Configuration
// conf/routes
# Routes
# This file defines all application routes (Higher priority routes first)
GET / controllers.HomeController.index
GET /api controllers.HomeController.api
# User API routes
GET /api/users controllers.UserController.list
GET /api/users/:id controllers.UserController.get(id: Long)
POST /api/users controllers.UserController.create
PUT /api/users/:id controllers.UserController.update(id: Long)
DELETE /api/users/:id controllers.UserController.delete(id: Long)
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
Authentication and Security
// app/controllers/AuthController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import play.api.Configuration
import pdi.jwt.{JwtJson, JwtAlgorithm}
import java.time.Clock
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Failure}
case class LoginRequest(email: String, password: String)
case class LoginResponse(token: String, user: User)
object LoginRequest {
implicit val format: Format[LoginRequest] = Json.format[LoginRequest]
}
object LoginResponse {
implicit val format: Format[LoginResponse] = Json.format[LoginResponse]
}
@Singleton
class AuthController @Inject()(
cc: ControllerComponents,
userRepository: UserRepository,
config: Configuration
)(implicit ec: ExecutionContext) extends AbstractController(cc) {
private val secretKey = config.get[String]("play.http.secret.key")
private implicit val clock: Clock = Clock.systemUTC
def login = Action.async(parse.json) { implicit request =>
request.body.validate[LoginRequest].fold(
errors => Future.successful(BadRequest(Json.obj("errors" -> JsError.toJson(errors)))),
loginRequest => {
// In a real app, you'd verify the password hash
authenticateUser(loginRequest.email, loginRequest.password).map {
case Some(user) =>
val token = generateToken(user)
Ok(Json.toJson(LoginResponse(token, user)))
case None =>
Unauthorized(Json.obj("error" -> "Invalid credentials"))
}
}
)
}
private def authenticateUser(email: String, password: String): Future[Option[User]] = {
// Simplified authentication - in real apps, use proper password hashing
userRepository.list().map(_.find(_.email == email))
}
private def generateToken(user: User): String = {
val claims = Json.obj(
"userId" -> user.id,
"email" -> user.email,
"exp" -> (System.currentTimeMillis() / 1000 + 3600) // 1 hour expiry
)
JwtJson.encode(claims, secretKey, JwtAlgorithm.HS256)
}
}
// app/actions/AuthenticatedAction.scala
package actions
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import play.api.Configuration
import pdi.jwt.{JwtJson, JwtAlgorithm}
import models.User
import scala.concurrent.{ExecutionContext, Future}
import scala.util.{Success, Failure}
class AuthenticatedRequest[A](val user: User, request: Request[A]) extends WrappedRequest[A](request)
@Singleton
class AuthenticatedAction @Inject()(
parser: BodyParsers.Default,
config: Configuration
)(implicit ec: ExecutionContext) extends ActionBuilderImpl(parser) {
private val secretKey = config.get[String]("play.http.secret.key")
override def invokeBlock[A](request: Request[A], block: Request[A] => Future[Result]): Future[Result] = {
request.headers.get("Authorization") match {
case Some(authHeader) if authHeader.startsWith("Bearer ") =>
val token = authHeader.substring(7)
JwtJson.decode(token, secretKey, Seq(JwtAlgorithm.HS256)) match {
case Success(claims) =>
val userId = (claims \ "userId").as[Long]
val email = (claims \ "email").as[String]
// In a real app, you'd fetch the user from the database
val user = User(Some(userId), "User", email, 0)
block(new AuthenticatedRequest(user, request))
case Failure(_) =>
Future.successful(Results.Unauthorized(Json.obj("error" -> "Invalid token")))
}
case _ =>
Future.successful(Results.Unauthorized(Json.obj("error" -> "Missing authorization header")))
}
}
}
Http4s: Purely Functional Web Services
Http4s provides a purely functional approach to web development with excellent composability and type safety.
Setting Up Http4s
// build.sbt
val Http4sVersion = "0.23.23"
val CirceVersion = "0.14.6"
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-server" % Http4sVersion,
"org.http4s" %% "http4s-ember-client" % Http4sVersion,
"org.http4s" %% "http4s-circe" % Http4sVersion,
"org.http4s" %% "http4s-dsl" % Http4sVersion,
"io.circe" %% "circe-generic" % CirceVersion,
"org.typelevel" %% "cats-effect" % "3.5.2",
"ch.qos.logback" % "logback-classic" % "1.4.11"
)
Basic Http4s Application
// src/main/scala/Main.scala
import cats.effect._
import org.http4s.ember.server.EmberServerBuilder
import com.comcast.ip4s._
object Main extends IOApp {
def run(args: List[String]): IO[ExitCode] = {
val server = EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(Routes.httpApp)
.build
server.use(_ => IO.never).as(ExitCode.Success)
}
}
Models and JSON Codecs
// src/main/scala/models/User.scala
package models
import io.circe.{Decoder, Encoder}
import io.circe.generic.semiauto._
case class User(
id: Option[Long],
name: String,
email: String,
age: Int
)
object User {
implicit val userDecoder: Decoder[User] = deriveDecoder[User]
implicit val userEncoder: Encoder[User] = deriveEncoder[User]
}
case class CreateUserRequest(name: String, email: String, age: Int)
object CreateUserRequest {
implicit val decoder: Decoder[CreateUserRequest] = deriveDecoder[CreateUserRequest]
}
case class ApiError(message: String)
object ApiError {
implicit val encoder: Encoder[ApiError] = deriveEncoder[ApiError]
}
Service Layer
// src/main/scala/services/UserService.scala
package services
import cats.effect._
import cats.implicits._
import models.{User, CreateUserRequest}
import scala.collection.mutable
trait UserService[F[_]] {
def getUsers: F[List[User]]
def getUser(id: Long): F[Option[User]]
def createUser(request: CreateUserRequest): F[User]
def updateUser(id: Long, request: CreateUserRequest): F[Option[User]]
def deleteUser(id: Long): F[Boolean]
}
class InMemoryUserService[F[_]: Sync] extends UserService[F] {
private val users = mutable.Map[Long, User]()
private var nextId = 1L
def getUsers: F[List[User]] = Sync[F].delay {
users.values.toList
}
def getUser(id: Long): F[Option[User]] = Sync[F].delay {
users.get(id)
}
def createUser(request: CreateUserRequest): F[User] = Sync[F].delay {
val user = User(Some(nextId), request.name, request.email, request.age)
users += (nextId -> user)
nextId += 1
user
}
def updateUser(id: Long, request: CreateUserRequest): F[Option[User]] = Sync[F].delay {
users.get(id).map { _ =>
val updatedUser = User(Some(id), request.name, request.email, request.age)
users += (id -> updatedUser)
updatedUser
}
}
def deleteUser(id: Long): F[Boolean] = Sync[F].delay {
users.remove(id).isDefined
}
}
HTTP Routes
// src/main/scala/Routes.scala
import cats.effect._
import cats.implicits._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.server.Router
import services.{UserService, InMemoryUserService}
import models.{User, CreateUserRequest, ApiError}
object Routes {
private val userService: UserService[IO] = new InMemoryUserService[IO]
private val userRoutes = HttpRoutes.of[IO] {
case GET -> Root / "users" =>
userService.getUsers.flatMap { users =>
Ok(users)
}
case GET -> Root / "users" / LongVar(id) =>
userService.getUser(id).flatMap {
case Some(user) => Ok(user)
case None => NotFound(ApiError(s"User with id $id not found"))
}
case req @ POST -> Root / "users" =>
req.as[CreateUserRequest].flatMap { createRequest =>
userService.createUser(createRequest).flatMap { user =>
Created(user)
}
}.handleErrorWith {
case _: InvalidMessageBodyFailure =>
BadRequest(ApiError("Invalid JSON"))
case ex =>
InternalServerError(ApiError(ex.getMessage))
}
case req @ PUT -> Root / "users" / LongVar(id) =>
req.as[CreateUserRequest].flatMap { updateRequest =>
userService.updateUser(id, updateRequest).flatMap {
case Some(user) => Ok(user)
case None => NotFound(ApiError(s"User with id $id not found"))
}
}.handleErrorWith {
case _: InvalidMessageBodyFailure =>
BadRequest(ApiError("Invalid JSON"))
case ex =>
InternalServerError(ApiError(ex.getMessage))
}
case DELETE -> Root / "users" / LongVar(id) =>
userService.deleteUser(id).flatMap { deleted =>
if (deleted) Ok(Map("message" -> "User deleted successfully"))
else NotFound(ApiError(s"User with id $id not found"))
}
}
private val healthRoutes = HttpRoutes.of[IO] {
case GET -> Root / "health" =>
Ok(Map(
"status" -> "healthy",
"timestamp" -> System.currentTimeMillis()
))
}
val httpApp = Router(
"/api" -> userRoutes,
"/" -> healthRoutes
).orNotFound
}
Middleware and Error Handling
// src/main/scala/middleware/ErrorHandling.scala
package middleware
import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import models.ApiError
object ErrorHandling {
def errorHandler[F[_]: Sync]: HttpRoutes[F] => HttpRoutes[F] = { routes =>
Kleisli { req =>
OptionT {
routes.run(req).value.handleError { error =>
val response = error match {
case _: IllegalArgumentException =>
Response[F](Status.BadRequest).withEntity(ApiError("Bad request"))
case _ =>
Response[F](Status.InternalServerError).withEntity(ApiError("Internal server error"))
}
Some(response)
}
}
}
}
}
// src/main/scala/middleware/Logging.scala
package middleware
import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.typelevel.log4cats.Logger
import org.typelevel.log4cats.slf4j.Slf4jLogger
object Logging {
def requestLogger[F[_]: Async]: HttpRoutes[F] => HttpRoutes[F] = { routes =>
implicit val logger: Logger[F] = Slf4jLogger.getLogger[F]
Kleisli { req =>
OptionT {
logger.info(s"${req.method} ${req.uri}") *>
routes.run(req).value.flatTap {
case Some(response) =>
logger.info(s"${req.method} ${req.uri} -> ${response.status}")
case None =>
logger.info(s"${req.method} ${req.uri} -> 404")
}
}
}
}
}
Authentication with Http4s
// src/main/scala/auth/AuthMiddleware.scala
package auth
import cats.effect._
import cats.data.{Kleisli, OptionT}
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.server.AuthMiddleware
import models.{User, ApiError}
import pdi.jwt.{JwtClaim, JwtCirce, JwtAlgorithm}
import io.circe.parser._
import scala.util.{Success, Failure}
case class AuthUser(id: Long, email: String)
object AuthMiddleware {
private val secretKey = "your-secret-key" // Use configuration in real apps
private def extractUser(token: String): Either[String, AuthUser] = {
JwtCirce.decode(token, secretKey, Seq(JwtAlgorithm.HS256)) match {
case Success(claim) =>
parse(claim.content) match {
case Right(json) =>
for {
id <- json.hcursor.get[Long]("userId").left.map(_.getMessage)
email <- json.hcursor.get[String]("email").left.map(_.getMessage)
} yield AuthUser(id, email)
case Left(error) => Left(error.getMessage)
}
case Failure(error) => Left(error.getMessage)
}
}
private val authUser: Kleisli[IO, Request[IO], Either[String, AuthUser]] =
Kleisli { request =>
IO.pure {
request.headers.get(ci"Authorization") match {
case Some(authHeader) if authHeader.head.value.startsWith("Bearer ") =>
val token = authHeader.head.value.substring(7)
extractUser(token)
case _ =>
Left("Missing or invalid authorization header")
}
}
}
private val onFailure: AuthedRoutes[String, IO] = Kleisli { _ =>
OptionT.liftF(Unauthorized(ApiError("Authentication required")))
}
val middleware: AuthMiddleware[IO, AuthUser] =
AuthMiddleware(authUser, onFailure)
// Protected routes
val protectedRoutes: AuthedRoutes[AuthUser, IO] = AuthedRoutes.of {
case GET -> Root / "profile" as user =>
Ok(Map(
"id" -> user.id,
"email" -> user.email,
"message" -> "This is a protected endpoint"
))
}
}
Testing Http4s Applications
// src/test/scala/RoutesSpec.scala
import cats.effect.IO
import org.http4s._
import org.http4s.circe.CirceEntityCodec._
import org.http4s.implicits._
import org.scalatest.matchers.should.Matchers
import org.scalatest.wordspec.AnyWordSpec
import models.{User, CreateUserRequest}
class RoutesSpec extends AnyWordSpec with Matchers {
"User routes" should {
"return empty list initially" in {
val request = Request[IO](Method.GET, uri"/api/users")
val response = Routes.httpApp.run(request).unsafeRunSync()
response.status shouldBe Status.Ok
response.as[List[User]].unsafeRunSync() shouldBe List.empty
}
"create a new user" in {
val createRequest = CreateUserRequest("John Doe", "john@example.com", 30)
val request = Request[IO](Method.POST, uri"/api/users")
.withEntity(createRequest)
val response = Routes.httpApp.run(request).unsafeRunSync()
response.status shouldBe Status.Created
val user = response.as[User].unsafeRunSync()
user.name shouldBe "John Doe"
user.email shouldBe "john@example.com"
user.age shouldBe 30
user.id shouldBe defined
}
"return 404 for non-existent user" in {
val request = Request[IO](Method.GET, uri"/api/users/999")
val response = Routes.httpApp.run(request).unsafeRunSync()
response.status shouldBe Status.NotFound
}
}
}
WebSocket Support
Play Framework WebSockets
// app/controllers/WebSocketController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.streams.ActorFlow
import akka.actor._
import akka.stream.Materializer
import play.api.libs.json._
@Singleton
class WebSocketController @Inject()(
cc: ControllerComponents
)(implicit system: ActorSystem, mat: Materializer) extends AbstractController(cc) {
def socket = WebSocket.accept[String, String] { request =>
ActorFlow.actorRef { out =>
ChatActor.props(out)
}
}
}
object ChatActor {
def props(out: ActorRef) = Props(new ChatActor(out))
}
class ChatActor(out: ActorRef) extends Actor {
def receive = {
case msg: String =>
// Echo back with timestamp
val response = s"Echo: $msg (${System.currentTimeMillis()})"
out ! response
}
}
Http4s WebSockets
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.websocket.WebSocketFrame
import org.http4s.server.websocket._
import fs2._
import fs2.concurrent.Queue
import cats.effect._
val webSocketRoutes = HttpRoutes.of[IO] {
case GET -> Root / "ws" =>
Queue.unbounded[IO, WebSocketFrame].flatMap { queue =>
val send = queue.dequeue.map(identity)
val receive = queue.enqueue
WebSocketBuilder[IO].build(send, receive.compose(_.map {
case WebSocketFrame.Text(text, _) =>
WebSocketFrame.Text(s"Echo: $text")
case frame => frame
}))
}
}
Performance Optimization
Caching Strategies
// Play Framework with Caffeine Cache
import com.github.benmanes.caffeine.cache.{Caffeine, Cache}
import scala.concurrent.duration._
@Singleton
class CachedUserService @Inject()(
userRepository: UserRepository
)(implicit ec: ExecutionContext) {
private val cache: Cache[Long, User] = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(10.minutes.toJava)
.build()
def findById(id: Long): Future[Option[User]] = {
Option(cache.getIfPresent(id)) match {
case Some(user) => Future.successful(Some(user))
case None =>
userRepository.findById(id).map { userOpt =>
userOpt.foreach(user => cache.put(id, user))
userOpt
}
}
}
}
// Http4s with Redis
import dev.profunktor.redis4cats._
import dev.profunktor.redis4cats.effect.Log.Stdout._
val redis: Resource[IO, RedisCommands[IO, String, String]] =
Redis[IO].utf8("redis://localhost")
def cachedUserService(redis: RedisCommands[IO, String, String]): UserService[IO] =
new UserService[IO] {
def getUser(id: Long): IO[Option[User]] = {
val key = s"user:$id"
redis.get(key).flatMap {
case Some(json) =>
IO.fromEither(decode[User](json)).map(Some(_))
case None =>
// Fetch from database and cache
fetchFromDatabase(id).flatTap {
case Some(user) =>
redis.setEx(key, user.asJson.noSpaces, 10.minutes)
case None => IO.unit
}
}
}
}
Conclusion
Web development in Scala offers powerful options for different use cases:
- Play Framework: Choose for traditional MVC web applications, rapid prototyping, or when you need a full-stack solution with integrated templating, asset pipeline, and extensive ecosystem
- Http4s: Choose for microservices, API-only backends, or when you want purely functional programming with excellent composability and type safety
Both frameworks provide:
- Type-safe routing and request handling
- JSON serialization/deserialization
- Authentication and authorization
- Testing frameworks
- Production-ready features
Key considerations:
- Learning curve: Play is more familiar to traditional web developers, Http4s requires functional programming knowledge
- Performance: Http4s generally has lower resource usage; Play provides more out-of-the-box optimizations
- Ecosystem: Play has a larger ecosystem and more third-party integrations
- Flexibility: Http4s is more modular and composable; Play provides more structure and conventions
Choose based on your team's expertise, project requirements, and architectural preferences.
Comments
Be the first to comment on this lesson!