Web Development with Play and HTTP4s | Scala Programming Guide

- Published on

Play Framework: Traditional MVC
Play is the most mature Scala web framework, following MVC patterns with convention over configuration.
Project Structure and Configuration
# conf/application.conf
play.i18n.langs = [ "en" ]
play.http.secret.key = "change-me"
# Database configuration
db {
default {
driver = "org.postgresql.Driver"
url = "jdbc:postgresql://localhost:5432/myapp"
username = "postgres"
password = "postgres"
}
}
# Logging
logger.application = DEBUG
logger.play = WARN
# Custom application settings
app {
maxUploadSize = 100MB
invoiceApi {
timeout = 30s
retries = 3
}
}
Controllers and Actions
Controllers handle HTTP requests and return responses:
// app/controllers/InvoiceController.scala
package controllers
import javax.inject._
import play.api.mvc._
import play.api.libs.json._
import scala.concurrent.{ExecutionContext, Future}
import services.InvoiceService
import models.Invoice
@Singleton
class InvoiceController @Inject() (
invoiceService: InvoiceService,
cc: ControllerComponents
)(implicit ec: ExecutionContext) extends AbstractController(cc) {
// GET /invoices/:id
// Returns single invoice as JSON
def getInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
invoiceService.findById(id).map {
case Some(invoice) =>
Ok(Json.toJson(invoice))
.withHeaders("Cache-Control" -> "max-age=300") // Cache 5 minutes
case None =>
NotFound(Json.obj("error" -> "Invoice not found"))
}
}
// GET /invoices?page=1&pageSize=20
// Returns paginated list
def listInvoices(page: Int = 1, pageSize: Int = 20): Action[AnyContent] =
Action.async { implicit request =>
// Validate pagination params
if (page < 1 || pageSize < 1 || pageSize > 100) {
Future.successful(
BadRequest(Json.obj("error" -> "Invalid pagination parameters"))
)
} else {
invoiceService.listPaginated(page, pageSize).map { invoices =>
Ok(Json.toJson(invoices))
}
}
}
// POST /invoices
// Body should be JSON invoice
def createInvoice(): Action[JsValue] = Action.async(parse.json) { request =>
// Validate JSON against Invoice model
request.body.validate[Invoice] match {
case JsSuccess(invoice, _) =>
invoiceService.create(invoice).map { created =>
Created(Json.toJson(created))
.withHeaders("Location" -> routes.InvoiceController.getInvoice(created.id).url)
}.recover {
case e: DuplicateKeyException =>
Conflict(Json.obj("error" -> s"Invoice ${invoice.id} already exists"))
case e: ValidationException =>
BadRequest(Json.obj("error" -> e.message))
}
case JsError(errors) =>
// errors is a Seq[(JsPath, Seq[JsonValidationError])]
val errorMessages = errors.map { case (path, validationErrors) =>
s"${path.toString()}: ${validationErrors.map(_.message).mkString(", ")}"
}
Future.successful(
BadRequest(Json.obj("errors" -> errorMessages))
)
}
}
// PUT /invoices/:id
// Update existing invoice
def updateInvoice(id: String): Action[JsValue] = Action.async(parse.json) { request =>
request.body.validate[Invoice] match {
case JsSuccess(updates, _) =>
invoiceService.update(id, updates).map {
case Some(updated) => Ok(Json.toJson(updated))
case None => NotFound(Json.obj("error" -> "Invoice not found"))
}
case JsError(errors) =>
Future.successful(BadRequest(Json.obj("errors" -> errors)))
}
}
// DELETE /invoices/:id
def deleteInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
invoiceService.delete(id).map { deleted =>
if (deleted) NoContent else NotFound
}
}
// POST /invoices/:id/send
// Send invoice via email
def sendInvoice(id: String): Action[AnyContent] = Action.async { implicit request =>
val recipientEmail = request.getQueryString("to").getOrElse {
return Future.successful(
BadRequest(Json.obj("error" -> "Missing 'to' parameter"))
)
}
invoiceService.sendInvoice(id, recipientEmail).map { _ =>
Ok(Json.obj("message" -> "Invoice sent successfully"))
}.recover {
case e: NotFoundException =>
NotFound(Json.obj("error" -> "Invoice not found"))
case e: EmailException =>
ServiceUnavailable(Json.obj("error" -> "Email service unavailable"))
}
}
}
Routes and Reverse Routing
# conf/routes
# REST API endpoints
GET /invoices controllers.InvoiceController.listInvoices(page: Int ?= 1, pageSize: Int ?= 20)
GET /invoices/:id controllers.InvoiceController.getInvoice(id: String)
POST /invoices controllers.InvoiceController.createInvoice()
PUT /invoices/:id controllers.InvoiceController.updateInvoice(id: String)
DELETE /invoices/:id controllers.InvoiceController.deleteInvoice(id: String)
POST /invoices/:id/send controllers.InvoiceController.sendInvoice(id: String)
# Health check
GET /health controllers.HealthController.status()
# Map static assets
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
Using reverse routing in code:
// In controller or view - generates URL at compile time
val invoiceUrl = routes.InvoiceController.getInvoice("INV-2024-001").url
// Result: /invoices/INV-2024-001
val listUrl = routes.InvoiceController.listInvoices(page = 2, pageSize = 50).url
// Result: /invoices?page=2&pageSize=50
Dependency Injection and Filters
// app/modules/AppModule.scala
package modules
import com.google.inject.AbstractModule
import com.google.inject.name.Names
import net.codingwell.scalaguice.ScalaModule
import services._
import filters._
import play.api.Configuration
class AppModule extends AbstractModule with ScalaModule {
override def configure(): Unit = {
// Bind interfaces to implementations
bind[InvoiceService].to[InvoiceServiceImpl].in[javax.inject.Singleton]
bind[EmailService].to[SendgridEmailService].in[javax.inject.Singleton]
// Conditional binding based on environment
if (play.api.Play.isDev) {
bind[PaymentGateway].to[MockPaymentGateway]
} else {
bind[PaymentGateway].to[StripePaymentGateway]
}
}
// Provider method for complex construction
@javax.inject.Provides
@javax.inject.Singleton
def provideHttpClient(config: Configuration): play.api.libs.ws.WSClient = {
val wsClient = play.api.libs.ws.ahc.AhcWSClient()
// Configure client settings
val timeout = config.get[scala.concurrent.duration.Duration]("http.client.timeout")
wsClient.withTimeout(timeout)
wsClient
}
}
// In application.conf, enable the module:
// play.modules.enabled += "modules.AppModule"
Filter middleware for cross-cutting concerns:
// app/filters/RequestLoggingFilter.scala
package filters
import javax.inject.Inject
import play.api.Logger
import play.api.mvc._
import scala.concurrent.{ExecutionContext, Future}
class RequestLoggingFilter @Inject() (implicit ec: ExecutionContext)
extends Filter {
private val logger = Logger(this.getClass)
def apply(
nextFilter: RequestHeader => Future[Result]
)(rh: RequestHeader): Future[Result] = {
val startTime = System.nanoTime()
nextFilter(rh).map { result =>
val endTime = System.nanoTime()
val duration = (endTime - startTime) / 1_000_000.0 // Convert to milliseconds
logger.info(
s"${rh.method} ${rh.uri} -> ${result.header.status} (${duration}ms)"
)
result.withHeaders(
"X-Response-Time" -> s"${duration}ms"
)
}.recover { case e =>
logger.error(s"Request failed: ${rh.method} ${rh.uri}", e)
throw e
}
}
}
// In application.conf:
// play.filters.enabled += "filters.RequestLoggingFilter"
Templates (Twirl)
Play uses Twirl for type-safe templates:
@* views/invoices/invoice.scala.html *@
@(invoice: models.Invoice, company: models.Company)
@defining(formatter.date.format(invoice.createdAt)) { createdDate =>
<div class="invoice">
<h1>Invoice #@invoice.id</h1>
<section class="company-info">
<h2>@company.name</h2>
<p>@company.address</p>
<p>@company.email</p>
</section>
<section class="items">
<table>
<thead>
<tr>
<th>Description</th>
<th>Quantity</th>
<th>Unit Price</th>
<th>Total</th>
</tr>
</thead>
<tbody>
@for(item <- invoice.items) {
<tr>
<td>@item.description</td>
<td>@item.quantity</td>
<td>@formatter.currency(item.unitPrice)</td>
<td>@formatter.currency(item.total)</td>
</tr>
}
</tbody>
</table>
</section>
<section class="totals">
<p>Subtotal: @formatter.currency(invoice.subtotal)</p>
<p>Tax (@{invoice.taxRate}%): @formatter.currency(invoice.taxAmount)</p>
<p class="total">Total: @formatter.currency(invoice.total)</p>
</section>
<footer>
<p>Generated on @createdDate</p>
</footer>
</div>
}
HTTP4s: Functional HTTP
HTTP4s is lightweight, built on cats-effect, emphasizing functional composition. Unlike Play Framework's object-oriented, imperative approach, HTTP4s uses composable functions and monadic composition to build services. This results in applications that are more testable, more easily reasoned about, and naturally suited to asynchronous processing.
The HttpRoutes DSL provides intuitive syntax for matching requests and composing responses. However, the power comes from the underlying cats-effect architecture: services are just IO computations that describe effects without executing them. This testability is remarkable—you can test routes without starting a server, without network I/O, without any actual effects occurring until you explicitly run the IO. The type system ensures that all effects are tracked and that resources are properly managed.
The functional approach to middleware and authentication differs significantly from traditional frameworks. Middleware isn't a side-effect-laden hook—it's a function that transforms routes into routes, composing cleanly with applicative and monadic combinators. This leads to more predictable behavior and easier debugging. Understanding how to work with OptionT, EitherT, and other monad transformers becomes essential as your services grow in complexity.
// build.sbt
libraryDependencies ++= Seq(
"org.http4s" %% "http4s-ember-server" % "1.0.0-M40",
"org.http4s" %% "http4s-dsl" % "1.0.0-M40",
"org.http4s" %% "http4s-circe" % "1.0.0-M40",
"io.circe" %% "circe-generic" % "0.14.6"
)
// Main application setup
import cats.effect._
import org.http4s._
import org.http4s.dsl.io._
import org.http4s.implicits._
import org.http4s.server.Router
import org.http4s.ember.server.EmberServerBuilder
import scala.concurrent.duration._
object Server extends IOApp.Simple {
// Define routes using DSL
val invoiceRoutes: HttpRoutes[IO] = HttpRoutes.of[IO] {
// GET /invoices/:id
case GET -> Root / "invoices" / id =>
// In a real app, fetch from database
val invoice = Invoice(
id = id,
amount = BigDecimal("1000.00"),
createdAt = Instant.now()
)
Ok(invoice) // Automatically converted to JSON via Circe
// GET /invoices?status=paid&limit=10
case GET -> Root / "invoices" :? StatusParam(status) +& LimitParam(limit) =>
// Query parameters extracted with custom matchers
val invoices = fetchInvoices(status, limit)
Ok(invoices)
// POST /invoices with JSON body
case req @ POST -> Root / "invoices" =>
req.as[Invoice].flatMap { invoice =>
// Validate and persist
val validated = validateInvoice(invoice)
validated match {
case Right(validInvoice) =>
saveInvoice(validInvoice).flatMap { saved =>
Created(saved, Location(Uri(path = Uri.Path.fromString(s"/invoices/${saved.id}"))))
}
case Left(errors) =>
BadRequest(ValidationError(errors.toString))
}
}
// DELETE /invoices/:id
case DELETE -> Root / "invoices" / id =>
deleteInvoice(id).flatMap { deleted =>
if (deleted) NoContent else NotFound()
}
}
// Custom query parameter matcher
object StatusParam extends QueryParamDecoderMatcher[String]("status")
object LimitParam extends OptionalQueryParamDecoderMatcher[Int]("limit")
// Middleware for logging
val loggingMiddleware: HttpApp[IO] => HttpApp[IO] = { app =>
val logger = org.log4s.getLogger
HttpRoutes.of[IO] { req =>
val start = System.currentTimeMillis()
app(req).map { resp =>
val elapsed = System.currentTimeMillis() - start
logger.info(s"${req.method.name} ${req.uri} -> ${resp.status.code} (${elapsed}ms)")
resp
}
}.orNotFound
}
// Middleware for error handling
val errorHandlingMiddleware: HttpApp[IO] => HttpApp[IO] = { app =>
HttpRoutes.of[IO] { req =>
app(req).recoverWith { case e: Throwable =>
Response[IO](
status = Status.InternalServerError,
body = fs2.Stream(s"Error: ${e.getMessage}".getBytes)
).pure[IO]
}
}.orNotFound
}
// Combine routes with middleware
val httpApp = Router(
"/" -> invoiceRoutes
).orNotFound
// Apply middleware (right to left: first applied = outermost)
val withMiddleware = loggingMiddleware(errorHandlingMiddleware(httpApp))
// Startup
def run: IO[Unit] =
EmberServerBuilder
.default[IO]
.withHost(org.http4s.Server.Host.fromString("0.0.0.0").get)
.withPort(org.http4s.Server.Port.fromInt(8080).get)
.withHttpApp(withMiddleware)
.build
.useForever
}
JSON Handling with Circe
import io.circe._
import io.circe.generic.auto._
import io.circe.parser._
// Automatic derivation for simple case classes
case class Invoice(
id: String,
amount: BigDecimal,
createdAt: Instant
) derives Codec.AsObject
// Custom encoding/decoding for complex types
implicit val instantDecoder: Decoder[Instant] = Decoder.decodeLong.map(Instant.ofEpochMilli)
implicit val instantEncoder: Encoder[Instant] = Encoder.encodeLong.contramap(_.toEpochMilli)
// Manual codec for fine-grained control
implicit val customInvoiceCodec: Codec.AsObject[Invoice] =
Codec.from(
// Decoder
Decoder.forProduct3("id", "amount", "created_at")(
(id: String, amount: BigDecimal, createdAt: Instant) =>
Invoice(id, amount, createdAt)
),
// Encoder
Encoder.AsObject.instance { invoice =>
JsonObject(
"id" -> Json.fromString(invoice.id),
"amount" -> Json.fromBigDecimal(invoice.amount),
"created_at" -> Json.fromLong(invoice.createdAt.toEpochMilli)
)
}
)
// Usage
val json = """{"id":"INV-001","amount":99.99,"created_at":1234567890000}"""
val decoded: Either[Error, Invoice] = decode[Invoice](json)
val encoded: String = invoice.asJson.noSpaces