CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/maven-com-typesafe-play--play-cache-2-11

Cache API for Play Framework applications providing both synchronous and asynchronous cache operations

Pending
Overview
Eval results
Files

action-caching.mddocs/

Action-Level HTTP Caching

Action-level HTTP caching provides built-in support for caching Play Framework action results with proper HTTP headers (ETag, Expires) for client-side caching coordination. This includes both server-side response caching and client cache validation.

Capabilities

Cached Helper Class (Scala)

The Cached class provides a fluent API for creating cacheable actions with various caching strategies.

/**
 * A helper to add caching to an Action
 */
class Cached @Inject() (cache: AsyncCacheApi)(implicit materializer: Materializer) {
  /**
   * Cache an action with custom key and caching function
   * @param key Compute a key from the request header
   * @param caching Compute a cache duration from the resource header
   * @return CachedBuilder for further configuration
   */
  def apply(key: RequestHeader => String, caching: PartialFunction[ResponseHeader, Duration]): CachedBuilder

  /**
   * Cache an action with custom key (no expiration)
   * @param key Compute a key from the request header
   * @return CachedBuilder for further configuration
   */
  def apply(key: RequestHeader => String): CachedBuilder

  /**
   * Cache an action with fixed key (no expiration)
   * @param key Cache key
   * @return CachedBuilder for further configuration
   */
  def apply(key: String): CachedBuilder

  /**
   * Cache an action with custom key and duration in seconds
   * @param key Compute a key from the request header
   * @param duration Cache duration in seconds
   * @return CachedBuilder for further configuration
   */
  def apply(key: RequestHeader => String, duration: Int): CachedBuilder

  /**
   * Cache an action with custom key and duration
   * @param key Compute a key from the request header
   * @param duration Cache duration
   * @return CachedBuilder for further configuration
   */
  def apply(key: RequestHeader => String, duration: Duration): CachedBuilder

  /**
   * A cached instance caching nothing (useful for composition)
   * @param key Compute a key from the request header
   * @return CachedBuilder for further configuration
   */
  def empty(key: RequestHeader => String): CachedBuilder

  /**
   * Caches everything, forever
   * @param key Compute a key from the request header
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String): CachedBuilder

  /**
   * Caches everything for the specified seconds
   * @param key Compute a key from the request header
   * @param duration Cache duration in seconds
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String, duration: Int): CachedBuilder

  /**
   * Caches everything for the specified duration
   * @param key Compute a key from the request header
   * @param duration Cache duration
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String, duration: Duration): CachedBuilder

  /**
   * Caches the specified status, for the specified number of seconds
   * @param key Compute a key from the request header
   * @param status HTTP status code to cache
   * @param duration Cache duration in seconds
   * @return CachedBuilder for further configuration
   */
  def status(key: RequestHeader => String, status: Int, duration: Int): CachedBuilder

  /**
   * Caches the specified status, for the specified duration
   * @param key Compute a key from the request header
   * @param status HTTP status code to cache
   * @param duration Cache duration
   * @return CachedBuilder for further configuration
   */
  def status(key: RequestHeader => String, status: Int, duration: Duration): CachedBuilder

  /**
   * Caches the specified status forever
   * @param key Compute a key from the request header
   * @param status HTTP status code to cache
   * @return CachedBuilder for further configuration
   */
  def status(key: RequestHeader => String, status: Int): CachedBuilder

  /**
   * A cached instance caching nothing (useful for composition)
   * @param key Compute a key from the request header
   * @return CachedBuilder for further configuration
   */
  def empty(key: RequestHeader => String): CachedBuilder

  /**
   * Caches everything, forever
   * @param key Compute a key from the request header
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String): CachedBuilder

  /**
   * Caches everything for the specified seconds
   * @param key Compute a key from the request header
   * @param duration Cache duration in seconds
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String, duration: Int): CachedBuilder

  /**
   * Caches everything for the specified duration
   * @param key Compute a key from the request header
   * @param duration Cache duration
   * @return CachedBuilder for further configuration
   */
  def everything(key: RequestHeader => String, duration: Duration): CachedBuilder
}

Usage Examples:

import play.api.cache.Cached
import play.api.mvc._
import javax.inject.Inject
import scala.concurrent.duration._

class ProductController @Inject()(
  cc: ControllerComponents,
  cached: Cached
) extends AbstractController(cc) {

  // Cache with request-based key
  def getProduct(id: String) = cached(req => s"product:$id", 1.hour) {
    Action { implicit request =>
      val product = loadProduct(id)
      Ok(Json.toJson(product))
    }
  }

  // Cache everything for 30 minutes
  def listProducts = cached.everything(req => "products", 30.minutes) {
    Action { implicit request =>
      val products = loadAllProducts()
      Ok(Json.toJson(products))
    }
  }

  // Cache only successful responses (200 status)
  def getProductDetails(id: String) = cached.status(req => s"product-details:$id", 200, 1.hour) {
    Action { implicit request =>
      loadProduct(id) match {
        case Some(product) => Ok(Json.toJson(product))
        case None => NotFound("Product not found")
      }
    }
  }

  // Conditional caching based on response headers
  def getProductWithConditionalCache(id: String) = cached(
    key = req => s"product-conditional:$id",
    caching = {
      case header if header.status == 200 => 1.hour
      case header if header.status == 404 => 5.minutes
    }
  ) {
    Action { implicit request =>
      loadProduct(id) match {
        case Some(product) => Ok(Json.toJson(product))
        case None => NotFound("Product not found")
      }
    }
  }
}

CachedBuilder Class (Scala)

The CachedBuilder provides additional configuration options for cached actions.

/**
 * Builds an action with caching behavior
 * Uses both server and client caches:
 * - Adds an Expires header to the response, so clients can cache response content
 * - Adds an Etag header to the response, so clients can cache response content and ask the server for freshness
 * - Cache the result on the server, so the underlying action is not computed at each call
 */
final class CachedBuilder(
    cache: AsyncCacheApi,
    key: RequestHeader => String,
    caching: PartialFunction[ResponseHeader, Duration]
)(implicit materializer: Materializer) {

  /**
   * Compose the cache with an action
   * @param action The action to cache
   * @return Cached EssentialAction
   */
  def apply(action: EssentialAction): EssentialAction

  /**
   * Compose the cache with an action
   * @param action The action to cache
   * @return Cached EssentialAction
   */
  def build(action: EssentialAction): EssentialAction

  /**
   * Whether this cache should cache the specified response if the status code match
   * This method will cache the result forever
   * @param status HTTP status code to cache
   * @return New CachedBuilder with status-based caching
   */
  def includeStatus(status: Int): CachedBuilder

  /**
   * Whether this cache should cache the specified response if the status code match
   * This method will cache the result for duration seconds
   * @param status HTTP status code to cache
   * @param duration Cache duration in seconds
   * @return New CachedBuilder with status-based caching
   */
  def includeStatus(status: Int, duration: Int): CachedBuilder

  /**
   * Whether this cache should cache the specified response if the status code match
   * This method will cache the result for duration
   * @param status HTTP status code to cache
   * @param duration Cache duration
   * @return New CachedBuilder with status-based caching
   */
  def includeStatus(status: Int, duration: Duration): CachedBuilder

  /**
   * The returned cache will store all responses whatever they may contain
   * @param duration Cache duration
   * @return New CachedBuilder with default caching
   */
  def default(duration: Duration): CachedBuilder

  /**
   * The returned cache will store all responses whatever they may contain
   * @param duration Cache duration in seconds
   * @return New CachedBuilder with default caching
   */
  def default(duration: Int): CachedBuilder

  /**
   * Whether this cache should cache the specified response if the status code match
   * This method will cache the result forever
   * @param status HTTP status code to cache
   * @return New CachedBuilder with status-based caching
   */
  def includeStatus(status: Int): CachedBuilder

  /**
   * Compose the cache with new caching function
   * @param alternative A closure getting the response header and returning the duration we should cache for
   * @return New CachedBuilder with composed caching logic
   */
  def compose(alternative: PartialFunction[ResponseHeader, Duration]): CachedBuilder
}

Usage Examples:

import play.api.cache.Cached
import play.api.mvc._
import javax.inject.Inject
import scala.concurrent.duration._

class ApiController @Inject()(
  cc: ControllerComponents,
  cached: Cached
) extends AbstractController(cc) {

  // Complex caching configuration with builder methods
  def getApiData(category: String) = cached
    .empty(req => s"api:$category")
    .includeStatus(200, 1.hour)      // Cache successful responses for 1 hour
    .includeStatus(404, 5.minutes)   // Cache not found responses for 5 minutes
    .default(30.seconds) {           // Default cache for other responses
    Action { implicit request =>
      loadApiData(category) match {
        case Some(data) => Ok(Json.toJson(data))
        case None => NotFound("Category not found")
      }
    }
  }

  // Composing multiple caching strategies
  def getDataWithComposition(id: String) = cached
    .status(req => s"data:$id", 200, 2.hours)
    .compose {
      case header if header.headers.contains("X-Cache-Control") => 10.minutes
      case header if header.status == 500 => Duration.Zero  // Don't cache errors
    } {
    Action { implicit request =>
      // Action implementation
      Ok("data")
    }
  }
}

@Cached Annotation (Java)

The @Cached annotation provides a simple way to cache action results in Java controllers.

/**
 * Mark an action to be cached on server side
 */
@With(CachedAction.class)
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Cached {
  /**
   * The cache key to store the result in
   * @return the cache key
   */
  String key();

  /**
   * The duration the action should be cached for. Defaults to 0.
   * @return the duration in seconds (0 = no expiration)
   */
  int duration() default 0;
}

CachedAction Class (Java)

The CachedAction class implements the caching behavior for @Cached annotated methods.

/**
 * Cache another action
 */
public class CachedAction extends Action<Cached> {
  @Inject
  public CachedAction(AsyncCacheApi cacheApi);

  /**
   * Execute the cached action
   * @param req HTTP request
   * @return CompletionStage containing the cached or computed result
   */
  public CompletionStage<Result> call(Request req);
}

Usage Examples:

import play.cache.Cached;
import play.mvc.*;
import javax.inject.Inject;
import java.util.concurrent.CompletionStage;

public class ProductController extends Controller {

    @Inject
    private ProductService productService;

    // Cache with fixed key and 1-hour expiration
    @Cached(key = "products.all", duration = 3600)
    public CompletionStage<Result> listProducts() {
        return productService.getAllProducts()
            .thenApply(products -> ok(Json.toJson(products)));
    }

    // Cache with dynamic key based on request parameter
    @Cached(key = "product.{id}", duration = 1800) // 30 minutes
    public CompletionStage<Result> getProduct(String id) {
        return productService.getProduct(id)
            .thenApply(product -> {
                if (product.isPresent()) {
                    return ok(Json.toJson(product.get()));
                } else {
                    return notFound("Product not found");
                }
            });
    }

    // Cache without expiration (permanent until manually evicted)
    @Cached(key = "product.categories")
    public CompletionStage<Result> getCategories() {
        return productService.getCategories()
            .thenApply(categories -> ok(Json.toJson(categories)));
    }

    // Cache at class level - applies to all methods
    @Cached(key = "admin.stats", duration = 300) // 5 minutes
    public static class AdminController extends Controller {
        
        public CompletionStage<Result> getStats() {
            return CompletableFuture.supplyAsync(() -> 
                ok(Json.toJson(generateStats())));
        }
    }
}

HTTP Caching Features

ETag and Client Cache Validation

The caching system automatically generates ETags and handles client cache validation:

// Automatic ETag generation based on content and expiration
// Client sends: If-None-Match: "etag-value"
// Server responds: 304 Not Modified if content unchanged

Cache Headers

Cached responses automatically include appropriate HTTP headers:

HTTP/1.1 200 OK
ETag: "a1b2c3d4e5f6"
Expires: Wed, 09 Sep 2025 21:30:00 GMT
Cache-Control: public, max-age=3600

SerializableResult

Internal class for serializing Play Results for caching:

/**
 * Wraps a Result to make it Serializable
 * Only strict entities can be cached, streamed entities cannot be cached
 */
private[play] final class SerializableResult(constructorResult: Result) extends Externalizable {
  def this()
  def result: Result
  override def readExternal(in: ObjectInput): Unit
  override def writeExternal(out: ObjectOutput): Unit
}

private[play] object SerializableResult {
  val encodingVersion: Byte = 2
}

Caching Strategies

Cache Key Strategies

// Static key
cached("homepage")

// Request-based key
cached(req => s"user:${req.session.get("userId").getOrElse("anonymous")}")

// Path-based key  
cached(req => s"api:${req.path}")

// Parameter-based key
cached(req => s"product:${req.getQueryString("id").getOrElse("all")}")

Duration Strategies

import scala.concurrent.duration._

// Fixed duration
cached(key, 1.hour)

// Conditional duration based on response
cached(key, {
  case header if header.status == 200 => 1.hour
  case header if header.status == 404 => 5.minutes  
  case _ => Duration.Zero  // Don't cache other responses
})

// Status-specific caching
cached.status(key, 200, 2.hours)  // Only cache 200 responses

Builder Pattern Combinations

cached.empty(key)
  .includeStatus(200, 1.hour)         // Cache successful responses
  .includeStatus(404, 5.minutes)      // Cache not found briefly
  .compose {                          // Add conditional logic
    case header if header.headers.contains("X-No-Cache") => Duration.Zero
    case _ => 30.seconds               // Default fallback
  }

Error Handling

UnsupportedOperationException

Some cache implementations may not support removeAll():

cache.removeAll().recover {
  case _: UnsupportedOperationException => 
    // Handle unsupported clear operation
    Done
}

Serialization Errors

Only strict HTTP entities can be cached:

// This will work - strict entity
Ok("Hello World")

// This will fail - streamed entity  
Ok.chunked(source)  // Throws IllegalStateException during caching

Cache Miss Handling

// Handle cache misses gracefully
cached.getOrElseUpdate("key", 1.hour) {
  computeExpensiveValue().recover {
    case ex: Exception => 
      logger.error("Failed to compute value", ex)
      defaultValue
  }
}

Install with Tessl CLI

npx tessl i tessl/maven-com-typesafe-play--play-cache-2-11

docs

action-caching.md

core-cache-operations.md

index.md

named-caches.md

tile.json