common/app/renderers/DotcomRenderingService.scala (416 lines of code) (raw):
package renderers
import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem}
import com.gu.contentapi.client.model.v1.{Block, Blocks, Content, Crossword}
import common.{DCRMetrics, GuLogging}
import concurrent.CircuitBreakerRegistry
import conf.Configuration
import conf.switches.Switches.CircuitBreakerDcrSwitch
import crosswords.CrosswordPageWithContent
import http.{HttpPreconnections, ResultWithPreconnectPreload}
import model.Cached.{RevalidatableResult, WithoutRevalidationResult}
import model.dotcomrendering._
import model.dotcomrendering.pageElements.EditionsCrosswordRenderingDataModel
import model.{
CacheTime,
Cached,
GalleryPage,
ImageContentPage,
InteractivePage,
LiveBlogPage,
MediaPage,
NoCache,
PageWithStoryPackage,
PressedPage,
RelatedContentItem,
SimplePage,
}
import play.api.libs.json.JsValue
import play.api.libs.ws.{WSClient, WSResponse}
import play.api.mvc.Results.{InternalServerError, NotFound}
import play.api.mvc.{RequestHeader, Result}
import play.twirl.api.Html
import services.newsletters.model.{NewsletterResponseV2, NewsletterLayout}
import services.{IndexPage, NewsletterData}
import java.lang.System.currentTimeMillis
import java.net.ConnectException
import java.util.concurrent.TimeoutException
import scala.concurrent.ExecutionContext.Implicits.global
import scala.concurrent.Future
import scala.concurrent.duration._
// Introduced as CAPI error handling elsewhere would smother these otherwise
case class DCRLocalConnectException(message: String) extends ConnectException(message)
case class DCRTimeoutException(message: String) extends TimeoutException(message)
case class DCRRenderingException(message: String) extends IllegalStateException(message)
class DotcomRenderingService extends GuLogging with ResultWithPreconnectPreload {
private[this] val circuitBreaker = CircuitBreakerRegistry.withConfig(
name = "dotcom-rendering-client",
system = PekkoActorSystem("dotcom-rendering-client-circuit-breaker"),
maxFailures = Configuration.rendering.circuitBreakerMaxFailures,
callTimeout = Configuration.rendering.timeout.plus(200.millis),
resetTimeout = Configuration.rendering.timeout * 4,
)
private[this] def postWithoutHandler(
ws: WSClient,
payload: JsValue,
endpoint: String,
requestId: Option[String],
timeout: Duration = Configuration.rendering.timeout,
)(implicit request: RequestHeader): Future[WSResponse] = {
val start = currentTimeMillis()
val request = ws
.url(endpoint)
.withRequestTimeout(timeout)
.addHttpHeaders("Content-Type" -> "application/json")
val resp = requestId match {
case Some(id) => request.addHttpHeaders("x-gu-xid" -> id).post(payload)
case None => request.post(payload)
}
resp.foreach(_ => {
DCRMetrics.DCRLatencyMetric.recordDuration(currentTimeMillis() - start)
DCRMetrics.DCRRequestCountMetric.increment()
})
resp.recoverWith({
case _: ConnectException if Configuration.environment.stage == "DEV" =>
val msg = s"""Connection refused to ${endpoint}.
|
|You are trying to access a Dotcom Rendering page via Frontend but it
|doesn't look like DCR is running locally on the expected port (3030).
|
|Note, for most use cases, we recommend developing directly on DCR.
|
|To get started with dotcom-rendering, see:
|
| https://github.com/guardian/dotcom-rendering""".stripMargin
Future.failed(DCRLocalConnectException(msg))
case t: TimeoutException => Future.failed(DCRTimeoutException(t.getMessage))
})
}
private[this] def post(
ws: WSClient,
payload: JsValue,
endpoint: String,
cacheTime: CacheTime,
timeout: Duration = Configuration.rendering.timeout,
)(implicit request: RequestHeader): Future[Result] = {
val requestId = request.headers.get("x-gu-xid")
def handler(response: WSResponse): Result = {
response.status match {
case 200 =>
val cachedRequest = Cached(cacheTime)(RevalidatableResult.Ok(Html(response.body)))
.withHeaders("X-GU-Dotcomponents" -> "true")
response.header("Link") match {
case Some(linkValue) =>
cachedRequest
// Send both the prefetch header for offline reading, and the usual preconnect URLs
.withHeaders("Link" -> linkValue)
.withPreconnect(HttpPreconnections.defaultUrls)
// For any other requests, we return just the default link header with preconnect urls
case _ => cachedRequest.withPreconnect(HttpPreconnections.defaultUrls)
}
case 400 =>
// if DCR returns a 400 it's because *we* failed, so frontend should return a 500
NoCache(InternalServerError("Remote renderer validation error (400)"))
.withHeaders("X-GU-Dotcomponents" -> "true")
case 415 =>
// if DCR returns a 415 it's because we can't render a specific component, so page is not available
Cached(CacheTime.NotFound)(WithoutRevalidationResult(NotFound))
.withHeaders("X-GU-Dotcomponents" -> "true")
case _ =>
log.error(s"Request to DCR failed: status ${response.status}, path: ${request.path}, body: ${response.body}")
NoCache(
InternalServerError("Remote renderer error (500)")
.withHeaders("X-GU-Dotcomponents" -> "true"),
)
}
}
if (CircuitBreakerDcrSwitch.isSwitchedOn) {
circuitBreaker
.withCircuitBreaker(postWithoutHandler(ws, payload, endpoint, requestId, timeout))
.map(handler)
} else {
postWithoutHandler(ws, payload, endpoint, requestId, timeout).map(handler)
}
}
def getAMPArticle(
ws: WSClient,
page: PageWithStoryPackage,
blocks: Blocks,
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
)(implicit request: RequestHeader): Future[Result] =
baseArticleRequest("/AMPArticle", ws, page, blocks, pageType, filterKeyEvents, false, newsletter)
def getAppsArticle(
ws: WSClient,
page: PageWithStoryPackage,
blocks: Blocks,
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
forceLive: Boolean = false,
)(implicit request: RequestHeader): Future[Result] =
baseArticleRequest(
"/AppsArticle",
ws,
page,
blocks,
pageType,
filterKeyEvents,
forceLive,
newsletter,
)
def getArticle(
ws: WSClient,
page: PageWithStoryPackage,
blocks: Blocks,
pageType: PageType,
newsletter: Option[NewsletterData],
filterKeyEvents: Boolean = false,
forceLive: Boolean = false,
)(implicit request: RequestHeader): Future[Result] =
baseArticleRequest(
"/Article",
ws,
page,
blocks,
pageType,
filterKeyEvents,
forceLive,
newsletter,
)
private def baseArticleRequest(
path: String,
ws: WSClient,
page: PageWithStoryPackage,
blocks: Blocks,
pageType: PageType,
filterKeyEvents: Boolean,
forceLive: Boolean = false,
newsletter: Option[NewsletterData],
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = page match {
case liveblog: LiveBlogPage =>
DotcomRenderingDataModel.forLiveblog(
liveblog,
blocks,
request,
pageType,
filterKeyEvents,
forceLive,
newsletter,
)
case _ => DotcomRenderingDataModel.forArticle(page, blocks, request, pageType, newsletter)
}
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + path, page.metadata.cacheTime)
}
def getBlocks(
ws: WSClient,
page: LiveBlogPage,
blocks: Seq[Block],
)(implicit request: RequestHeader): Future[String] = {
val dataModel = DotcomBlocksRenderingDataModel(page, request, blocks)
val json = DotcomBlocksRenderingDataModel.toJson(dataModel)
val requestId = request.headers.get("x-gu-xid")
postWithoutHandler(ws, json, Configuration.rendering.articleBaseURL + "/Blocks", requestId)
.flatMap(response => {
if (response.status == 200)
Future.successful(response.body)
else
Future.failed(
DCRRenderingException(
s"getBlocks request to DCR failed: status ${response.status}, path: ${request.path}, body: ${response.body}",
),
)
})
}
def getAppsBlocks(
ws: WSClient,
page: LiveBlogPage,
blocks: Seq[Block],
)(implicit request: RequestHeader): Future[String] = {
val dataModel = DotcomBlocksRenderingDataModel(page, request, blocks)
val json = DotcomBlocksRenderingDataModel.toJson(dataModel)
val requestId = request.headers.get("x-gu-xid")
postWithoutHandler(ws, json, Configuration.rendering.articleBaseURL + "/AppsBlocks", requestId)
.flatMap(response => {
if (response.status == 200)
Future.successful(response.body)
else
Future.failed(
DCRRenderingException(
s"getBlocks request to DCR failed: status ${response.status}, path: ${request.path}, body: ${response.body}",
),
)
})
}
private def getTimeout: Duration = {
if (Configuration.environment.stage == "DEV")
Configuration.rendering.timeout * 5
else
Configuration.rendering.timeout
}
def getFront(
ws: WSClient,
page: PressedPage,
pageType: PageType,
mostViewed: Seq[RelatedContentItem],
mostCommented: Option[Content],
mostShared: Option[Content],
deeplyRead: Option[Seq[Trail]],
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomFrontsRenderingDataModel(
page,
request,
pageType,
mostViewed,
mostCommented,
mostShared,
deeplyRead,
)
val json = DotcomFrontsRenderingDataModel.toJson(dataModel)
val timeout = getTimeout
post(ws, json, Configuration.rendering.faciaBaseURL + "/Front", CacheTime.Facia, timeout)
}
def getTagPage(
ws: WSClient,
page: IndexPage,
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomTagPagesRenderingDataModel(
page,
request,
pageType,
)
val json = DotcomTagPagesRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.tagPageBaseURL + "/TagPage", CacheTime.Facia)
}
def getInteractive(
ws: WSClient,
page: InteractivePage,
blocks: Blocks,
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
// Nb. interactives have a longer timeout because some of them are very
// large unfortunately. E.g.
// https://www.theguardian.com/education/ng-interactive/2018/may/29/university-guide-2019-league-table-for-computer-science-information.
post(ws, json, Configuration.rendering.interactiveBaseURL + "/Interactive", page.metadata.cacheTime, 4.seconds)
}
def getAMPInteractive(
ws: WSClient,
page: InteractivePage,
blocks: Blocks,
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.interactiveBaseURL + "/AMPInteractive", page.metadata.cacheTime)
}
def getAppsInteractive(
ws: WSClient,
page: InteractivePage,
blocks: Blocks,
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forInteractive(page, blocks, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
// Nb. interactives have a longer timeout because some of them are very
// large unfortunately. E.g.
// https://www.theguardian.com/education/ng-interactive/2018/may/29/university-guide-2019-league-table-for-computer-science-information.
post(ws, json, Configuration.rendering.interactiveBaseURL + "/AppsInteractive", page.metadata.cacheTime, 4.seconds)
}
def getEmailNewsletters(
ws: WSClient,
newsletters: List[NewsletterResponseV2],
layout: Option[NewsletterLayout],
page: SimplePage,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomNewslettersPageRenderingDataModel.apply(page, newsletters, layout, request)
val json = DotcomNewslettersPageRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.faciaBaseURL + "/EmailNewsletters", CacheTime.Facia)
}
def getImageContent(
ws: WSClient,
imageContent: ImageContentPage,
pageType: PageType,
mainBlock: Option[Block],
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forImageContent(imageContent, request, pageType, mainBlock)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/Article", imageContent.metadata.cacheTime)
}
def getAppsImageContent(
ws: WSClient,
imageContent: ImageContentPage,
pageType: PageType,
mainBlock: Option[Block],
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forImageContent(imageContent, request, pageType, mainBlock)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", imageContent.metadata.cacheTime)
}
def getMedia(
ws: WSClient,
mediaPage: MediaPage,
pageType: PageType,
blocks: Blocks,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forMedia(mediaPage, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/Article", mediaPage.metadata.cacheTime)
}
def getAppsMedia(
ws: WSClient,
mediaPage: MediaPage,
pageType: PageType,
blocks: Blocks,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forMedia(mediaPage, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/AppsArticle", mediaPage.metadata.cacheTime)
}
def getGallery(
ws: WSClient,
gallery: GalleryPage,
pageType: PageType,
blocks: Blocks,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forGallery(gallery, request, pageType, blocks)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/Article", gallery.metadata.cacheTime)
}
def getCrossword(
ws: WSClient,
crosswordPage: CrosswordPageWithContent,
pageType: PageType,
)(implicit request: RequestHeader): Future[Result] = {
val dataModel = DotcomRenderingDataModel.forCrossword(crosswordPage, request, pageType)
val json = DotcomRenderingDataModel.toJson(dataModel)
post(ws, json, Configuration.rendering.articleBaseURL + "/Article", CacheTime.Crosswords)
}
def getEditionsCrossword(
ws: WSClient,
crosswords: EditionsCrosswordRenderingDataModel,
)(implicit request: RequestHeader): Future[Result] = {
val json = EditionsCrosswordRenderingDataModel.toJson(crosswords)
post(ws, json, Configuration.rendering.articleBaseURL + "/EditionsCrossword", CacheTime.Default)
}
def getFootballPage(
ws: WSClient,
json: JsValue,
)(implicit request: RequestHeader): Future[Result] = {
post(ws, json, Configuration.rendering.articleBaseURL + "/FootballMatchListPage", CacheTime.Football)
}
def getFootballMatchSummaryPage(
ws: WSClient,
json: JsValue,
)(implicit request: RequestHeader): Future[Result] = {
post(ws, json, Configuration.rendering.articleBaseURL + "/FootballMatchSummaryPage", CacheTime.FootballMatch)
}
def getCricketPage(
ws: WSClient,
json: JsValue,
)(implicit request: RequestHeader): Future[Result] = {
post(ws, json, Configuration.rendering.articleBaseURL + "/CricketMatchPage", CacheTime.Cricket)
}
def getFootballTablesPage(
ws: WSClient,
json: JsValue,
)(implicit request: RequestHeader): Future[Result] = {
post(ws, json, Configuration.rendering.articleBaseURL + "/FootballTablesPage", CacheTime.FootballTables)
}
}
object DotcomRenderingService {
def apply(): DotcomRenderingService = new DotcomRenderingService()
}