app/controllers/Application.scala (110 lines of code) (raw):
package com.gu.itunes
import com.gu.contentapi.client.model.v1.ItemResponse
import com.gu.contentapi.client.model.{ ContentApiError, ItemQuery }
import org.joda.time.format.DateTimeFormat
import org.joda.time.{ DateTime, DateTimeZone, Duration }
import org.scalactic.{ Bad, Good }
import org.slf4j.LoggerFactory
import play.api.mvc.Results._
import play.api.mvc.{ BaseController, ControllerComponents, Result }
import play.api.{ Configuration, Logger }
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
case class Failed(message: String, status: Status) {
override val toString: String =
s"message: $message, status: ${status.header.status}"
}
class Application(val controllerComponents: ControllerComponents, val config: Configuration) extends BaseController {
private val logger = LoggerFactory.getLogger(getClass)
val apiKey = SecretKeeper.getApiKey(config).
getOrElse(sys.error("You must provide a CAPI key, either in secrets manager, application.conf or as the API_KEY environment variable"))
val maxAge = 300
val staleWhileRevalidateSeconds = 600
val oneDayInSeconds = 86400
val cacheControl = s"max-age=$maxAge, stale-while-revalidate=$staleWhileRevalidateSeconds, stale-if-error=$oneDayInSeconds"
private val HTTPDateFormat = DateTimeFormat.forPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'").withZone(DateTimeZone.UTC)
private val imageResizerSignatureSalt: Option[String] = SecretKeeper.getImageResizerSignatureSalt(config)
def itunesRss(tagId: String, userApiKey: Option[String]) = Action.async { implicit request =>
val startTime = DateTime.now
val userAgent = request.headers.get("user-agent").getOrElse("")
logger.info(s"Received request for tag '$tagId' from user agent '$userAgent'")
val redirect = Redirection.redirect(tagId)
val eventualResult = redirect match {
case Some(redirectedTagId) => Future.successful(MovedPermanently(routes.Application.itunesRss(redirectedTagId, userApiKey).absoluteURL(true)))
case None =>
rawRss(tagId, userApiKey)
}
eventualResult.map { result =>
logger.info(s"Returning response status ${result.header.status} for tag '${tagId} after ${durationSince(startTime)}")
result
}.recover {
case t: Throwable =>
logger.warn(s"Failed to complete for tag '$tagId after ${durationSince(startTime)}", t)
InternalServerError("Could not complete request")
}
}
private def rawRss(tagId: String, userApiKey: Option[String]): Future[Result] = {
val client = new CustomCapiClient(apiKey)
val maxItems = 300
val pageSize = 100
val query = ItemQuery(tagId)
.showElements("audio,image")
.showTags("keyword")
.showFields("webTitle,webPublicationDate,standfirst,trailText,internalComposerCode")
def fetchItemsWithPagination(query: ItemQuery, page: Int = 1, resps: Seq[ItemResponse] = Seq.empty): Future[Seq[ItemResponse]] = {
logger.debug("Fetching page: " + page + " with page size: " + pageSize)
val withPagination = query.page(page).pageSize(pageSize)
client.getResponse(withPagination).flatMap { resp =>
val responses = resps :+ resp
// Paginate if we have not covered the required number of pages and there are more pages available
val lastRequiredPage = (maxItems / pageSize) + (if (maxItems % pageSize > 0) { 1 } else { 0 })
val shouldPaginate = (page < lastRequiredPage) && resp.pages.getOrElse(0) > page
if (shouldPaginate) {
// Recurse with the results and pagination incremented
fetchItemsWithPagination(query, page + 1, responses)
} else {
logger.info("Finished fetching " + responses.map(_.results.map(_.size).getOrElse(0)).sum + " items after paginating to page " + page)
Future.successful(responses)
}
}
}
(for {
itemResponses <- fetchItemsWithPagination(query)
userApiKeyTier <- userApiKey.map { userApiKey =>
// If an external partner has identified themselves using an api-key we will query to resolve the user tier
new CustomCapiClient(userApiKey).getResponse(ItemQuery(tagId).pageSize(0)).map { resp =>
Some(resp.userTier)
}
}.getOrElse {
Future.successful(None)
}
} yield {
// If all item responses were ok then we can render a result
if (itemResponses.forall(_.status == "ok")) {
val isAdFree = userApiKeyTier.contains("rights-managed")
iTunesRssFeed(itemResponses, isAdFree, imageResizerSignatureSalt) match {
case Good(xml) =>
val now = DateTime.now()
val expiresTime = now.plusSeconds(maxAge)
Ok(xml).withHeaders(
"Surrogate-Control" -> cacheControl,
"Cache-Control" -> cacheControl,
"Expires" -> expiresTime.toString(HTTPDateFormat),
"Date" -> now.toString(HTTPDateFormat))
case Bad(failed: Failed) =>
logger.warn(s"Failed to render XML. tagId = $tagId, ${failed.toString}")
failed.status
}
} else {
NotFound
}
}).recover {
case ContentApiError(404, _, _) => NotFound
case ContentApiError(403, _, _) => Forbidden
case ContentApiError(401, _, _) => Unauthorized
// maybe this generic InternalServerError could be a better representation of the CAPI failure mode
case ContentApiError(status, msg, errorResponse) =>
logger.warn(s"Unexpected response code from CAPI. tagId = $tagId, HTTP status = $status, error response = $errorResponse")
InternalServerError
}
}
def healthcheck = Action {
Ok("OK")
}
private def durationSince(time: DateTime): String = new Duration(time, DateTime.now).getMillis + "ms"
}