From d1360b2917b314a35ebf72781bb31b949030fefd Mon Sep 17 00:00:00 2001 From: Artemiy Solopov Date: Fri, 18 Sep 2026 19:14:53 +0300 Subject: [PATCH] Some ETag refactoring --- app/controllers/HomeController.scala | 32 ++++++++------------- app/controllers/caching/caching.scala | 41 +++++++++++++++++++++++---- 2 files changed, 48 insertions(+), 25 deletions(-) diff --git a/app/controllers/HomeController.scala b/app/controllers/HomeController.scala index 2a1abe8..90ffc2b 100644 --- a/app/controllers/HomeController.scala +++ b/app/controllers/HomeController.scala @@ -1,17 +1,15 @@ package controllers -import javax.inject.* import play.api.* -import play.api.mvc.* -import authentication.{AuthenticatedAction, AuthenticatedRequest} -import caching.{Etagable, CheckFresh} +import _root_.controllers.authentication.{AuthenticatedAction, AuthenticatedRequest} +import _root_.controllers.caching.{CheckFresh, DigestEtagable} import models.User import play.api.libs.json.Json +import play.api.mvc.* -import java.nio.ByteBuffer +import java.io.DataOutputStream import java.nio.charset.StandardCharsets.UTF_8 -import java.security.MessageDigest -import java.util.Base64 +import javax.inject.* /** * This controller creates an `Action` to handle HTTP requests to the @@ -37,21 +35,15 @@ class HomeController @Inject()(val controllerComponents: ControllerComponents, val user = request.user checkFresh(user) { // TODO: put the max-age somewhere into config - Ok(Json.toJson(user)).withHeaders("Etag" -> user.etag, "Cache-Control" -> "private, max-age=15, must-revalidate") + Ok(Json.toJson(user)).withHeaders("Cache-Control" -> "private, max-age=15, must-revalidate") } } - private given Etagable[models.User] with { - private val base64 = Base64.getUrlEncoder - - extension (a: User) - override def etag: String = { - val md = MessageDigest.getInstance("SHA-1") - md.update(ByteBuffer.allocate(8).putLong(a.id.getOrElse(0))) - md.update(a.gid.getBytes(UTF_8)) - md.update(a.username.getBytes(UTF_8)) - - base64.encodeToString(md.digest()) - } + private given DigestEtagable[models.User] with { + override protected def writeDigestData(a: User, s: DataOutputStream): Unit = { + s.writeLong(a.id.getOrElse(0)) + s.write(a.gid.getBytes(UTF_8)) + s.write(a.username.getBytes(UTF_8)) + } } } diff --git a/app/controllers/caching/caching.scala b/app/controllers/caching/caching.scala index 2bac898..ab9f5ef 100644 --- a/app/controllers/caching/caching.scala +++ b/app/controllers/caching/caching.scala @@ -3,24 +3,55 @@ package controllers.caching import play.api.mvc.Results.NotModified import play.api.mvc.{Request, Result} +import java.io.{ByteArrayOutputStream, DataOutputStream, OutputStream} +import java.security.{DigestOutputStream, MessageDigest} +import java.util.Base64 import scala.concurrent.{ExecutionContext, Future} +import scala.util.Using trait Etagable[A]: extension (a: A) def etag: String +trait DigestEtagable[A] extends Etagable[A]: + private val base64 = Base64.getUrlEncoder.withoutPadding + + protected def writeDigestData(a: A, s: DataOutputStream): Unit + + extension (a: A) { + override def etag: String = { + val md = MessageDigest.getInstance("SHA-1") + Using(DataOutputStream(DigestOutputStream(OutputStream.nullOutputStream(), md))) { dos => + writeDigestData(a, dos) + dos.flush() + } + + base64.encodeToString(md.digest()) + } + } + + trait CheckFresh { protected def checkFreshAsync[A, E: Etagable](entity: E)(onStale: => Future[Result])(using request: Request[A], ec: ExecutionContext): Future[Result] = { - compareEtag(request, entity).fold(onStale)( - etag => Future.successful(NotModified.withHeaders("ETag" -> etag)) + compareEtag(request, entity).fold( + entityEtag => onStale.map(_.withHeaders("etag" -> entityEtag)), + requestEtag => Future.successful(notModified(requestEtag)) ) } protected def checkFresh[A, E: Etagable](entity: E)(onStale: => Result)(using request: Request[A]): Result = { - compareEtag(request, entity).fold(onStale)( - etag => NotModified.withHeaders("ETag" -> etag) + compareEtag(request, entity).fold( + entityEtag => onStale.withHeaders("etag" -> entityEtag), + requestEtag => notModified(requestEtag) ) } - private def compareEtag[A, E: Etagable](request: Request[A], entity: E) = request.headers.get("If-None-Match").filter(e => e == entity.etag) + private def compareEtag[A, E: Etagable](request: Request[A], entity: E) = { + val entityEtag = entity.etag + request.headers.get("If-None-Match").filter(e => e == entityEtag) match { + case Some(etag) => Right(etag) + case None => Left(entityEtag) + } + } + private def notModified(etag: String) = NotModified.withHeaders("etag" -> etag) } \ No newline at end of file