facia-press/app/frontpress/FapiFrontPress.scala (534 lines of code) (raw):

package frontpress import metrics.SamplerMetric import com.gu.contentapi.client.{ContentApiClient => CapiContentApiClient} import com.gu.contentapi.client.model.v1.ItemResponse import com.gu.contentapi.client.model.{ItemQuery, SearchQuery} import com.gu.facia.api.contentapi.ContentApi.{AdjustItemQuery, AdjustSearchQuery} import com.gu.facia.api.models.{Collection, Front} import com.gu.facia.api.{FAPI, Response} import com.gu.facia.client.ApiClient import com.gu.facia.client.models.{Breaking, Canonical, ConfigJson, Metadata, Special} import common.LoggingField.LogFieldString import common._ import common.commercial.CommercialProperties import conf.Configuration import conf.switches.Switches import conf.switches.Switches.FaciaInlineEmbeds import contentapi._ import services.fronts.FrontsApi import model.{PressedPage, _} import model.facia.PressedCollection import model.pressed._ import org.joda.time.DateTime import play.api.libs.json._ import play.api.libs.ws.{WSClient, WSResponse} import services.{ConfigAgent, S3FrontsApi} import implicits.Booleans._ import layout.slices.Container import scala.concurrent.{ExecutionContext, Future} import scala.util.{Failure, Success} class LiveFapiFrontPress(val wsClient: WSClient, val capiClientForFrontsSeo: ContentApiClient)(implicit ec: ExecutionContext, ) extends FapiFrontPress { override def putPressedJson(path: String, json: String, pressedType: PressedPageType): Unit = S3FrontsApi.putLiveFapiPressedJson(path, json, pressedType) override def isLiveContent: Boolean = true override implicit val capiClient: CapiContentApiClient = CircuitBreakingContentApiClient( httpClient = new CapiHttpClient(wsClient), targetUrl = Configuration.contentApi.contentApiHost, apiKey = Configuration.contentApi.key.getOrElse("facia-press"), ) implicit val fapiClient: ApiClient = FrontsApi.crossAccountClient override def collectionContentWithSnaps( collection: Collection, adjustSearchQuery: AdjustSearchQuery = identity, adjustSnapItemQuery: AdjustItemQuery = identity, ): Response[List[PressedContent]] = FAPI .liveCollectionContentWithSnaps(collection, adjustSearchQuery, adjustSnapItemQuery) .map( _.map((item) => PressedContent.make(item, collection.collectionConfig.displayHints.flatMap(_.suppressImages).getOrElse(false)), ), ) } class DraftFapiFrontPress(val wsClient: WSClient, val capiClientForFrontsSeo: ContentApiClient)(implicit ec: ExecutionContext, ) extends FapiFrontPress { override implicit val capiClient: CapiContentApiClient = CircuitBreakingContentApiClient( httpClient = new CapiHttpClient(wsClient) { override val signer = Some(PreviewSigner()) }, targetUrl = Configuration.contentApi.previewHost .filter(_ => Switches.FaciaToolDraftContent.isSwitchedOn) .getOrElse(Configuration.contentApi.contentApiHost), apiKey = Configuration.contentApi.key.getOrElse("facia-press"), ) implicit val fapiClient: ApiClient = FrontsApi.crossAccountClient override def putPressedJson(path: String, json: String, pressedType: PressedPageType): Unit = S3FrontsApi.putDraftFapiPressedJson(path, json, pressedType) override def isLiveContent: Boolean = false override def collectionContentWithSnaps( collection: Collection, adjustSearchQuery: AdjustSearchQuery = identity, adjustSnapItemQuery: AdjustItemQuery = identity, ): Response[List[PressedContent]] = FAPI .draftCollectionContentWithSnaps(collection, adjustSearchQuery, adjustSnapItemQuery) .map( _.map((item) => PressedContent.make(item, collection.collectionConfig.displayHints.flatMap(_.suppressImages).getOrElse(false)), ), ) } // This is the json structure we expect for an embed (know as a snap at render-time). final case class EmbedJsonHtml( html: String, ) object EmbedJsonHtml { implicit val format: OFormat[EmbedJsonHtml] = Json.format[EmbedJsonHtml] } case class EmailFrontPath(path: String, edition: String) object EmailFrontPath { def fromPath(path: String): Option[EmailFrontPath] = path match { case "email/uk/daily" => Some(EmailFrontPath(path, "uk")) case "email/us/daily" => Some(EmailFrontPath(path, "us")) case "email/au/daily" => Some(EmailFrontPath(path, "au")) case "email/europe/daily" => Some(EmailFrontPath(path, "europe")) case _ => None } } case class EmailExtraCollections( canonical: Option[PressedCollectionVisibility], special: List[PressedCollectionVisibility], breaking: List[PressedCollectionVisibility], ) trait EmailFrontPress extends GuLogging { implicit def fapiClient: ApiClient def generatePressedVersions( path: String, allPressedCollections: List[PressedCollectionVisibility], seoData: SeoData, frontProperties: FrontProperties, ): PressedPageVersions def collectionsIdsFromConfigForPath(path: String, config: ConfigJson): List[String] def generateCollectionJsonFromFapiClient(collectionId: String)(implicit executionContext: ExecutionContext, ): Response[PressedCollectionVisibility] def getFrontSeoAndProperties(path: String)(implicit executionContext: ExecutionContext, ): Future[(SeoData, FrontProperties)] def pressEmailFront( emailFrontPath: EmailFrontPath, )(implicit executionContext: ExecutionContext): Response[PressedPageVersions] = { for { config <- Response.Async.Right(fapiClient.config) collectionIds = collectionsIdsFromConfigForPath(emailFrontPath.path, config) pressedCollections <- Response.traverse(collectionIds.map(generateCollectionJsonFromFapiClient)) extraEmailCollections <- buildExtraEmailCollections(emailFrontPath, config, pressedCollections) seoWithProperties <- Response.Async.Right(getFrontSeoAndProperties(emailFrontPath.path)) } yield seoWithProperties match { case (seoData, frontProperties) => val allPressedCollections = mergeExtraEmailCollections(pressedCollections, extraEmailCollections).map(_.withoutTrailTextOnTail) generatePressedVersions(emailFrontPath.path, allPressedCollections, seoData, frontProperties) } } private def mergeExtraEmailCollections( pressedCollections: List[PressedCollectionVisibility], emailCollections: EmailExtraCollections, ): List[PressedCollectionVisibility] = { emailCollections.breaking ::: emailCollections.canonical.toList ::: emailCollections.special ::: pressedCollections } private def buildExtraEmailCollections( frontPath: EmailFrontPath, config: ConfigJson, pressedCollections: List[PressedCollectionVisibility], )(implicit ec: ExecutionContext): Response[EmailExtraCollections] = { def findCollectionIds(metadata: Metadata): List[String] = { for { front <- config.fronts.get(frontPath.edition).toList collectionId <- front.collections collectionConfig <- config.collections.get(collectionId) if collectionConfig.metadata.exists(_.contains(metadata)) } yield collectionId } def findMetaContainersWithLimit(metadata: Metadata, limit: Int): Response[List[PressedCollectionVisibility]] = { Response .traverse(findCollectionIds(metadata).map(generateCollectionJsonFromFapiClient)) .map(_.map(_.withVisible(limit))) } val canonicalPressedF = findMetaContainersWithLimit(Canonical, 6) val breakingPressedF = findMetaContainersWithLimit(Breaking, 5) val specialPressedF = findMetaContainersWithLimit(Special, 1) for { canonicalPressed <- canonicalPressedF breakingPressed <- breakingPressedF specialPressed <- specialPressedF } yield EmailExtraCollections(canonicalPressed.headOption, specialPressed, breakingPressed) } } trait FapiFrontPress extends EmailFrontPress with GuLogging { val dependentFrontPaths: Map[String, Seq[String]] = Map( "uk" -> Seq("email/uk/daily"), "us" -> Seq("email/us/daily"), "au" -> Seq("email/au/daily"), ) implicit val capiClient: CapiContentApiClient implicit def fapiClient: ApiClient val capiClientForFrontsSeo: ContentApiClient val wsClient: WSClient def putPressedJson(path: String, json: String, pressedType: PressedPageType): Unit def isLiveContent: Boolean def collectionContentWithSnaps( collection: Collection, adjustSearchQuery: AdjustSearchQuery = identity, adjustSnapItemQuery: AdjustItemQuery = identity, ): Response[List[PressedContent]] val showFields = "displayHint,trailText,headline,shortUrl,liveBloggingNow,thumbnail,commentable,commentCloseDate,shouldHideAdverts,lastModified,byline,standfirst,starRating,showInRelatedContent,internalPageCode,main" val showBlocks = TrailsToRss.BlocksToGenerateRssIntro val searchApiQuery: AdjustSearchQuery = (searchQuery: SearchQuery) => searchQuery .showSection(true) .showFields(showFields) .showElements("all") .showTags("all") .showReferences(QueryDefaults.references) .showAtoms("media") .showBlocks(showBlocks) val itemApiQuery: AdjustItemQuery = (itemQuery: ItemQuery) => itemQuery .showSection(true) .showFields(showFields) .showElements("all") .showTags("all") .showReferences(QueryDefaults.references) .showAtoms("media") .showBlocks(showBlocks) def pressByPathId(path: String, messageId: String)(implicit executionContext: ExecutionContext): Future[Unit] = { def pressDependentPaths(paths: Seq[String]): Future[Unit] = { Future .traverse(paths)(p => pressPath(p, messageId)) .recover { case e => log.error(s"Error when pressing $paths", e) } .map(_ => ()) } for { _ <- pressPath(path, messageId) _ <- pressDependentPaths(dependentFrontPaths.getOrElse(path, Nil)) } yield () } private def pressPath(path: String, messageId: String)(implicit executionContext: ExecutionContext): Future[Unit] = { val stopWatch: StopWatch = new StopWatch val pressFuture = getPressedFrontForPath(path) .map { pressedFronts: PressedPageVersions => // temporary logging to investigate fronts weirdness on code - log entire front out if (Configuration.environment.stage == "CODE") { logInfoWithCustomFields( s"Pressed data for front $path : ${Json.stringify(Json.toJson(pressedFronts.full))} ", customFields = List( LogFieldString("messageId", messageId), LogFieldString("pressPath", path), ), ) } putPressedPage(path, pressedFronts.full, FullType) putPressedPage(path, pressedFronts.lite, LiteType) putPressedPage(path, pressedFronts.fullAdFree, FullAdFreeType) putPressedPage(path, pressedFronts.liteAdFree, LiteAdFreeType) } .fold( e => { StatusNotification.notifyFailedJob(path, isLive = isLiveContent, e) e.cause.map(throw _).getOrElse(throw new RuntimeException(e.message)) }, _ => StatusNotification.notifyCompleteJob(path, isLive = isLiveContent), ) pressFuture.onComplete { case Success(_) => val pressDuration: Long = stopWatch.elapsed log.info(s"Successfully pressed $path in $pressDuration ms") FaciaPressMetrics.AllFrontsPressLatencyMetric.recordDuration(pressDuration.toDouble) /** We record separate metrics for each of the editions' network fronts */ val metricsByPath = Map( "uk" -> FaciaPressMetrics.UkPressLatencyMetric, "us" -> FaciaPressMetrics.UsPressLatencyMetric, "au" -> FaciaPressMetrics.AuPressLatencyMetric, ) if (Edition.byId(path).isDefined) { metricsByPath.get(path).foreach { metric => metric.recordDuration(pressDuration.toDouble) } } case Failure(error) => log.warn(s"Failed to press '$path':", error) } pressFuture } private def putPressedPage(path: String, pressedFront: PressedPage, pressedType: PressedPageType): Unit = { val json: String = Json.stringify(Json.toJson(pressedFront)) val metric: SamplerMetric = pressedType match { case FullType => FaciaPressMetrics.FrontPressContentSize case LiteType => FaciaPressMetrics.FrontPressContentSizeLite case LiteAdFreeType => FaciaPressMetrics.FrontPressContentSizeLite case FullAdFreeType => FaciaPressMetrics.FrontPressContentSize } metric.recordSample(json.getBytes.length, new DateTime()) putPressedJson(path, json, pressedType) } def generateCollectionJsonFromFapiClient( collectionId: String, )(implicit executionContext: ExecutionContext): Response[PressedCollectionVisibility] = { for { collection <- FAPI.getCollection(collectionId) curated <- getCurated(collection) backfill <- getBackfill(collection) treats <- getTreats(collection) } yield { val storyCountTotal = curated.length + backfill.length val storyCountMax: Int = collection.collectionConfig.collectionType match { // nav/list stories should never be capped case "nav/list" => storyCountTotal // other container types should be capped at a maximum number of stories set in the app config case _ => Math.min(Configuration.facia.collectionCap, storyCountTotal) } val storyCountVisible = Container .storiesCount( CollectionConfig.make(collection.collectionConfig), curated ++ backfill, ) .getOrElse(storyCountMax) val pressedCollection = pressCollection(collection, curated, backfill, treats, storyCountMax) PressedCollectionVisibility(pressedCollection, storyCountVisible) } } private def pressCollection( collection: Collection, curated: List[PressedContent], backfill: List[PressedContent], treats: List[PressedContent], storyCount: Int, ) = { val trimmedCurated = curated.take(storyCount) val trimmedBackfill = backfill.take(storyCount - trimmedCurated.length) PressedCollection.fromCollectionWithCuratedAndBackfill( collection, trimmedCurated, trimmedBackfill, treats, ) } private def getCurated( collection: Collection, )(implicit executionContext: ExecutionContext): Response[List[PressedContent]] = { // Map initial PressedContent to enhanced content which contains pre-fetched embed content. val initialContent = collectionContentWithSnaps(collection, searchApiQuery, itemApiQuery) initialContent.flatMap { content => Response.traverse(content.map { case curated: CuratedContent if FaciaInlineEmbeds.isSwitchedOn => enrichContent(collection, curated, curated.enriched).map { updatedFields => curated.copy(enriched = Some(updatedFields)) } case link: LinkSnap if FaciaInlineEmbeds.isSwitchedOn => enrichContent(collection, link, link.enriched).map { updatedFields => link.copy(enriched = Some(updatedFields)) } case plain => Response.Right(plain) }) } } private def enrichContent(collection: Collection, content: PressedContent, enriched: Option[EnrichedContent])(implicit executionContext: ExecutionContext, ): Response[EnrichedContent] = { val beforeEnrichment = enriched.getOrElse(EnrichedContent.empty) val maybeUpdate = content.properties.embedType match { case Some("json.html") => Enrichment.enrichSnap(content.properties.embedUri, beforeEnrichment, collection, wsClient) case Some("interactive") => Enrichment.enrichInteractive(content.properties.atomId, beforeEnrichment, collection, capiClient) case _ => Future.successful(beforeEnrichment) } Response(maybeUpdate.map(scala.Right.apply)) } private def getTreats( collection: Collection, )(implicit executionContext: ExecutionContext): Response[List[PressedContent]] = { FAPI .getTreatsForCollection(collection, searchApiQuery, itemApiQuery) .map(_.map((item) => PressedContent.make(item, false))) } private def getBackfill( collection: Collection, )(implicit executionContext: ExecutionContext): Response[List[PressedContent]] = { FAPI .backfillFromConfig(collection.collectionConfig, searchApiQuery, itemApiQuery) .map(_.map(((item) => PressedContent.make(item, false)))) } def generatePressedVersions( path: String, allPressedCollections: List[PressedCollectionVisibility], seoData: SeoData, frontProperties: FrontProperties, ): PressedPageVersions = { val webCollections = allPressedCollections.filter(PressedCollectionVisibility.isWebCollection) val deduplicatedCollections = PressedCollectionDeduplication .deduplication(webCollections) .map(_.pressedCollectionVersions) .toList PressedPageVersions.fromPressedCollections(path, seoData, frontProperties, deduplicatedCollections) } def collectionsIdsFromConfigForPath(path: String, config: ConfigJson): List[String] = { Front .frontsFromConfig(config) .find(_.id == path) .map(_.collections) .getOrElse { log.warn(s"There are no collections for path $path") throw new IllegalStateException(s"There are no collections for path $path") } } def getPressedFrontForPath( path: String, )(implicit executionContext: ExecutionContext): Response[PressedPageVersions] = { EmailFrontPath.fromPath(path).fold(pressFront(path))(pressEmailFront) } def pressFront(path: String)(implicit executionContext: ExecutionContext): Response[PressedPageVersions] = { for { config <- Response.Async.Right(fapiClient.config) collectionIds = collectionsIdsFromConfigForPath(path, config) pressedCollections <- Response.traverse(collectionIds.map(generateCollectionJsonFromFapiClient)) seoWithProperties <- Response.Async.Right(getFrontSeoAndProperties(path)) } yield seoWithProperties match { case (seoData, frontProperties) => generatePressedVersions(path, pressedCollections, seoData, frontProperties) } } def getFrontSeoAndProperties( path: String, )(implicit executionContext: ExecutionContext): Future[(SeoData, FrontProperties)] = { for { itemResp <- getCapiItemResponseForPath(path) } yield { val seoFromConfig = ConfigAgent.getSeoDataJsonFromConfig(path) val seoFromPath = SeoData.fromPath(path) val navSection: String = seoFromConfig.navSection .orElse(itemResp.flatMap(getNavSectionFromItemResponse)) .getOrElse(seoFromPath.navSection) val webTitle: String = seoFromConfig.webTitle .orElse(itemResp.flatMap(getWebTitleFromItemResponse)) .getOrElse(seoFromPath.webTitle) val title: Option[String] = seoFromConfig.title val description: Option[String] = seoFromConfig.description .orElse(SeoData.descriptionFromWebTitle(webTitle)) val frontProperties: FrontProperties = ConfigAgent .getFrontProperties(path) .copy( editorialType = itemResp.flatMap(_.tag).map(_.`type`.name), /* * We expect the capi response for a front to have exclusively either a tag or a section or neither, * according to whether it is a section front, a tag page or a page unknown to capi respectively. * Thus the order in which tag and section are processed is unimportant. */ commercial = { val tag = itemResp flatMap (_.tag) val section = itemResp flatMap (_.section) tag.map(CommercialProperties.fromTag) orElse section.map(CommercialProperties.fromSection) orElse CommercialProperties.forNetworkFront(path) orElse Some(CommercialProperties.forFrontUnknownToCapi(path)) }, ) val seoData: SeoData = SeoData(path, navSection, webTitle, title, description) (seoData, frontProperties) } } private def getNavSectionFromItemResponse(itemResponse: ItemResponse): Option[String] = itemResponse.tag .flatMap(_.sectionId) .orElse(itemResponse.section.map(_.id).map(removeLeadEditionFromSectionId)) private def getWebTitleFromItemResponse(itemResponse: ItemResponse): Option[String] = itemResponse.tag .map(_.webTitle) .orElse(itemResponse.section.map(_.webTitle)) // This will turn au/culture into culture. We want to stay consistent with the manual entry and autogeneration private def removeLeadEditionFromSectionId(sectionId: String): String = sectionId.split('/').toList match { case edition :: tail if Edition.byId(edition).isDefined => tail.mkString("/") case _ => sectionId } private def getCapiItemResponseForPath( id: String, )(implicit executionContext: ExecutionContext): Future[Option[ItemResponse]] = { val contentApiResponse: Future[ItemResponse] = capiClientForFrontsSeo.getResponse( capiClientForFrontsSeo .item(id, Edition.defaultEdition) .showEditorsPicks(false) .pageSize(0), ) contentApiResponse.foreach { _ => log.info(s"Getting SEO data from content API for $id") } contentApiResponse.failed.foreach { e: Throwable => log.warn(s"Error getting SEO data from content API for $id: $e") } contentApiResponse.map(Option(_)).fallbackTo(Future.successful(None)) } } object Enrichment extends GuLogging { def enrichSnap( embedUri: Option[String], beforeEnrichment: EnrichedContent, collection: Collection, wsClient: WSClient, )(implicit executionContext: ExecutionContext): Future[EnrichedContent] = { def enrich(response: WSResponse): Option[EnrichedContent] = { val jsResult = Json.fromJson[EmbedJsonHtml](response.json) val jsOption = jsResult match { case JsSuccess(embed, _) => Some(embed) case _ => None } jsOption.map { embed => beforeEnrichment.copy(embedHtml = Some(embed.html)) } } val result = for { embedUri <- asFut(embedUri, "missing embedUri") response <- wsClient.url(embedUri).get() enriched <- asFut(enrich(response), s"failed to enrich snap $embedUri") } yield enriched result.recover { case error => { log.warn(s"Processing a snap failed, skipping: ${error.toString()}") beforeEnrichment } } } def enrichInteractive( atomId: Option[String], beforeEnrichment: EnrichedContent, collection: Collection, capiClient: CapiContentApiClient, )(implicit executionContext: ExecutionContext): Future[EnrichedContent] = { def enrich(response: ItemResponse): Option[EnrichedContent] = { for { interactive <- response.interactive enriched <- Some(interactive.data).flatMap { case atom: com.gu.contentatom.thrift.AtomData.Interactive => Some( beforeEnrichment.copy( embedHtml = Some(atom.interactive.html), embedCss = Some(atom.interactive.css), embedJs = atom.interactive.mainJS, ), ) case _ => None } } yield enriched } val result = for { atomId <- asFut(atomId, "atomId was undefined") itemResponse <- capiClient.getResponse(ItemQuery(atomId)) enriched <- asFut(enrich(itemResponse), s"failed to enrich atom $atomId") } yield enriched result.failed.foreach { error => val msg = s"Processing of an interactive atom failed, and it won't be pressed: $error" log.warn(msg) } result } private def asFut[A](opt: Option[A], errMsg: String): Future[A] = { opt match { case Some(thing) => Future.successful(thing) case None => Future.failed(new Throwable(errMsg)) } } }