Skip to content

Commit

Permalink
Support recipe schema.org metadata from CAPI in LinkedData (#26931)
Browse files Browse the repository at this point in the history
* Retrieves new CAPI model content field `schemaOrg` in CAPI item requests
* Renames fields (`_atType`, `_atContext` to `@type`, `@context`) due to a Thrift incompatibility with the `@` sign
* Includes the `recipe` part of the `schemaOrg` object in the `LinkedData` for consumption by DCR

---------

Co-authored-by: frederickobrien <[email protected]>
Co-authored-by: Andy Gallagher <[email protected]>
Co-authored-by: Alina Boghiu <[email protected]>
  • Loading branch information
4 people authored Mar 18, 2024
1 parent 198684c commit 213c2bc
Show file tree
Hide file tree
Showing 51 changed files with 246 additions and 28 deletions.
4 changes: 4 additions & 0 deletions common/app/model/content.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package model
import java.net.URL

import com.gu.contentapi.client.model.{v1 => contentapi}
import com.gu.contentapi.client.model.schemaorg.SchemaOrg
import com.gu.facia.api.{utils => fapiutils}
import com.gu.facia.client.models.TrailMetaData
import com.gu.targeting.client.Campaign
Expand Down Expand Up @@ -67,6 +68,7 @@ final case class Content(
wordCount: Int,
showByline: Boolean,
rawOpenGraphImage: Option[ImageAsset],
schemaOrg: Option[SchemaOrg],
) {

lazy val isBlog: Boolean = tags.blogs.nonEmpty
Expand Down Expand Up @@ -434,6 +436,7 @@ object Content {
val references: Map[String, String] =
apiContent.references.map(ref => (ref.`type`, Reference.split(ref.id)._2)).toMap
val cardStyle: fapiutils.CardStyle = CardStylePicker(apiContent)
val schemaOrg = apiContent.schemaOrg

Content(
trail = trail,
Expand Down Expand Up @@ -478,6 +481,7 @@ object Content {
.flatten
.orElse(elements.mainPicture.flatMap(_.images.largestImage))
.orElse(trail.trailPicture.flatMap(_.largestImage)),
schemaOrg = schemaOrg,
)
}
}
Expand Down
131 changes: 105 additions & 26 deletions common/app/model/dotcomrendering/LinkedData.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package model.dotcomrendering

import com.gu.contentapi.client.model.schemaorg.SchemaRecipe
import com.gu.contentapi.client.model.v1.{Block => CAPIBlock}
import model.{Article, ContentType, ImageMedia, Interactive, LiveBlogPage, Tags}
import org.joda.time.DateTime
Expand All @@ -9,18 +10,21 @@ import play.api.libs.json.Reads._
import play.api.libs.json._
import views.support.{FourByThree, ImgSrc, Item1200, OneByOne}

import scala.util.matching.Regex

object LinkedData {

implicit val formats: OFormat[LinkedData] = new OFormat[LinkedData] {
override def writes(ld: LinkedData): JsObject =
ld match {
case guardian: Guardian => Json.toJsObject(guardian)(Guardian.formats)
case wp: WebPage => Json.toJsObject(wp)(WebPage.formats)
case il: ItemList => Json.toJsObject(il)(ItemList.formats)
case na: NewsArticle => Json.toJsObject(na)(NewsArticle.formats)
case re: Review => Json.toJsObject(re)(Review.formats)
case lb: LiveBlogPosting => Json.toJsObject(lb)(LiveBlogPosting.formats)
case po: BlogPosting => Json.toJsObject(po)(BlogPosting.formats)
case guardian: Guardian => Json.toJsObject(guardian)(Guardian.formats)
case wp: WebPage => Json.toJsObject(wp)(WebPage.formats)
case il: ItemList => Json.toJsObject(il)(ItemList.formats)
case na: NewsArticle => Json.toJsObject(na)(NewsArticle.formats)
case re: Review => Json.toJsObject(re)(Review.formats)
case lb: LiveBlogPosting => Json.toJsObject(lb)(LiveBlogPosting.formats)
case po: BlogPosting => Json.toJsObject(po)(BlogPosting.formats)
case rp: RecipeLinkedData => Json.toJsObject(rp)(RecipeLinkedData.formats)
}

override def reads(json: JsValue): JsResult[LinkedData] =
Expand Down Expand Up @@ -85,6 +89,24 @@ object LinkedData {
): List[LinkedData] = {
val authors = getAuthors(article.tags)

val articleLinkedData: List[LinkedData] = List(
NewsArticle(
`@id` = baseURL + "/" + article.metadata.id,
image = getImagesForArticle(article, fallbackLogo),
author = authors,
datePublished = article.trail.webPublicationDate.toString(),
dateModified = article.fields.lastModified.toString(),
headline = article.trail.headline,
mainEntityOfPage = article.metadata.webUrl,
),
WebPage(
`@id` = article.metadata.webUrl,
potentialAction = Some(
PotentialAction(target = "android-app://com.guardian/" + article.metadata.webUrl.replace("://", "/")),
),
),
)

article match {
case filmReview if article.content.imdb.isDefined && article.tags.isReview => {
article.content.imdb.toList.map(ref =>
Expand All @@ -95,25 +117,11 @@ object LinkedData {
),
)
}
case newsArticle => {
List(
NewsArticle(
`@id` = baseURL + "/" + article.metadata.id,
image = getImagesForArticle(article, fallbackLogo),
author = authors,
datePublished = article.trail.webPublicationDate.toString(),
dateModified = article.fields.lastModified.toString(),
headline = article.trail.headline,
mainEntityOfPage = article.metadata.webUrl,
),
WebPage(
`@id` = article.metadata.webUrl,
potentialAction = Some(
PotentialAction(target = "android-app://com.guardian/" + article.metadata.webUrl.replace("://", "/")),
),
),
)
}
// We need to convert the Seq[SchemaRecipe] to List[Recipe] (to satisfy type match of List[LinkedData]
case recipeArticle if article.content.schemaOrg.flatMap(_.recipe).getOrElse(Seq()).nonEmpty =>
val recipes = recipeArticle.content.schemaOrg.flatMap(_.recipe).getOrElse(Seq())
articleLinkedData ++ recipes.map(RecipeLinkedData.apply)
case _ => articleLinkedData
}
}

Expand Down Expand Up @@ -419,3 +427,74 @@ object LiveBlogPosting {
implicit val formats: OFormat[LiveBlogPosting] = Json.format[LiveBlogPosting]

}

case class RecipeLinkedData(
`@type`: String = "Recipe",
`@context`: String = "http://schema.org",
content: SchemaRecipe,
) extends LinkedData

object RecipeLinkedData {
/*
What's all this about? Well, schema.org relies on fields prefixed with the `@` symbol as internal type discriminators.
Unfortunately, if we generate the schema.org upstream in Concierge we need to format it into Thrift to be passed into Frontend.
Thrift objects to the usage of the `@` symbol in field names.... so instead of `@type` we had to pass `_atType`.
When we render it back out to JSON, though, we need to replace the `_at` prefix with `@` (and fix the case of the first char).
This is done by defining a custom naming scheme in Play json - see https://www.playframework.com/documentation/3.0.x/ScalaJsonAutomated#Implementing-your-own-Naming-Strategy
for more details.
The scheme is defined by the `SchemaOrgNaming` static object, which is effectively a filter for every field name as it is serialized.
That's then wired into the formatter by overriding the implicit `JsonConfiguration` object _before_ defining the implicit formatters below -
as a result, _anything_ with the `@` prefix gets fixed
*/
object SchemaOrgNaming extends JsonNaming {
private val atField = "^_at(\\w)(.*)$".r
override def apply(property: String): String =
property match {
case atField(leadingChar, tail) => s"@${leadingChar.toLowerCase}$tail"
case _ => property
}
}

implicit val config: JsonConfiguration = JsonConfiguration(SchemaOrgNaming)

implicit val authorInfo: OFormat[com.gu.contentapi.client.model.schemaorg.AuthorInfo] =
Json.format[com.gu.contentapi.client.model.schemaorg.AuthorInfo]

implicit val schemaRecipeStep: OFormat[com.gu.contentapi.client.model.schemaorg.RecipeStep] =
Json.format[com.gu.contentapi.client.model.schemaorg.RecipeStep]
implicit val schemaFormat: OFormat[SchemaRecipe] = Json.format[SchemaRecipe]

/*
We define a manual formatter write here, just in order to be able to have a case class which simultaneously satisfies
the `LinkedData` trait but also contains the entire schema.org object with the funkily named fields and can be serialized
directly into valid schema.org json
*/
implicit val formats: OFormat[RecipeLinkedData] = new OFormat[RecipeLinkedData] {
def writes(d: RecipeLinkedData) =
Json.obj(
"@context" -> d.`@context`,
"@type" -> d.`@type`,
"name" -> d.content.name,
"description" -> d.content.description,
"image" -> d.content.image,
"datePublished" -> d.content.datePublished,
"url" -> d.content.url,
"recipeCategory" -> d.content.recipeCategory,
"recipeCuisine" -> d.content.recipeCuisine,
"recipeIngredient" -> d.content.recipeIngredient,
"recipeInstructions" -> d.content.recipeInstructions,
"recipeYield" -> d.content.recipeYield,
"prepTime" -> d.content.prepTime,
"cookTime" -> d.content.cookTime,
"totalTime" -> d.content.totalTime,
"author" -> d.content.author,
"suitableForDiet" -> d.content.suitableForDiet,
)

override def reads(json: JsValue): JsResult[RecipeLinkedData] =
throw new RuntimeException("Unexpected attempt to read RecipeLinkedData")
}

def apply(from: SchemaRecipe) =
new RecipeLinkedData(`@type` = from._atType, `@context` = from._atContext, content = from)
}
1 change: 1 addition & 0 deletions common/app/services/CAPILookup.scala
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ class CAPILookup(contentApiClient: ContentApiClient) {
.showFields("all")
.showReferences("all")
.showAtoms("all")
.showSchemaOrg(true)

val capiItemWithBlocks = range
.map { blockRange =>
Expand Down
133 changes: 133 additions & 0 deletions common/test/model/dotcomrendering/LinkedDataTest.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
package model.dotcomrendering

import com.gu.contentapi.client.model.schemaorg.{RecipeStep, SchemaOrg, SchemaRecipe}
import com.gu.contentapi.client.model.v1.{CapiDateTime, Tag, TagType, Content => ApiContent}
import com.gu.contentapi.client.utils.CapiModelEnrichment.RichOffsetDateTime
import model.{Article, Content, ContentType, DotcomContentType, MetaData, RelatedContent}
import conf.Configuration
import org.mockito.Mockito.when
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatestplus.mockito.MockitoSugar
import test.{TestRequest, WithTestExecutionContext}
import org.joda.time.{DateTime, DateTimeZone}

import java.time.ZoneOffset
import implicits.Dates.jodaToJavaInstant
import play.api.libs.json.Json

class LinkedDataTest extends AnyFlatSpec with Matchers with MockitoSugar {

val publishDate = Some(jodaToJavaInstant(new DateTime()).atOffset(ZoneOffset.UTC).toCapiDateTime)

val testArticle = {
val item = ApiContent(
id = "foo/2012/jan/07/bar",
sectionId = None,
sectionName = None,
webPublicationDate = publishDate,
webTitle = "Some article",
webUrl = "http://www.guardian.co.uk/foo/2012/jan/07/bar",
apiUrl = "http://content.guardianapis.com/foo/2012/jan/07/bar",
tags = List(),
elements = None,
schemaOrg = None,
)
Article.make(Content.make(item))
}

val testArticleWithRecipe = {
val item = ApiContent(
id = "foo/2012/jan/07/bar",
sectionId = None,
sectionName = None,
webPublicationDate = publishDate,
webTitle = "Some article",
webUrl = "http://www.guardian.co.uk/foo/2012/jan/07/bar",
apiUrl = "http://content.guardianapis.com/foo/2012/jan/07/bar",
tags = List(),
elements = None,
schemaOrg = Some(
SchemaOrg(
recipe = Some(
Seq(
SchemaRecipe(
_atContext = "http://schema.org",
_atType = "Recipe",
name = Some("Test recipe"),
description = Some("This is yummy"),
image = Some("https://path.to/image/on/server.jpg"),
datePublished = Some("2012-01-02T03:04:05Z"),
url = Some("https://path.to/content/on/server.html"),
recipeCategory = Some(Seq("test", "food")),
recipeCuisine = Some(Seq("test", "British")),
recipeIngredient = Some(Seq("23 litres of sprunge", "6 baked beans")),
recipeInstructions = Some(
Seq(
RecipeStep(
_atType = "HowToStep",
text = "Open the can",
name = Some("Open"),
url = None,
image = None,
),
RecipeStep(
_atType = "HowToStep",
text = "Pour the contents",
name = Some("Pour"),
url = None,
image = None,
),
),
),
recipeYield = Some(Seq("1 serving")),
prepTime = Some("30 seconds"),
cookTime = Some("10 hours"),
totalTime = Some("10 hours 30 seconds"),
author = Some(
com.gu.contentapi.client.model.schemaorg
.AuthorInfo(_atType = "Person", name = "John Smith", sameAs = None),
),
suitableForDiet = Some(Seq("https://schema.org/VeganDiet", "https://schema.org/VegetarianDiet")),
),
),
),
),
),
)
Article.make(Content.make(item))
}

/// This string should always correct validate at https://validator.schema.org/
val expectedRecipeJson =
"""{"@context":"http://schema.org","@type":"Recipe","name":"Test recipe","description":"This is yummy","image":"https://path.to/image/on/server.jpg","datePublished":"2012-01-02T03:04:05Z","url":"https://path.to/content/on/server.html","recipeCategory":["test","food"],"recipeCuisine":["test","British"],"recipeIngredient":["23 litres of sprunge","6 baked beans"],"recipeInstructions":[{"@type":"HowToStep","text":"Open the can","name":"Open"},{"@type":"HowToStep","text":"Pour the contents","name":"Pour"}],"recipeYield":["1 serving"],"prepTime":"30 seconds","cookTime":"10 hours","totalTime":"10 hours 30 seconds","author":{"@type":"Person","name":"John Smith"},"suitableForDiet":["https://schema.org/VeganDiet","https://schema.org/VegetarianDiet"]}"""

"LinkedData.forArticle" should "return news article linkedData" in {
val linkedData = LinkedData.forArticle(
article = testArticle,
baseURL = Configuration.dotcom.baseUrl,
fallbackLogo = Configuration.images.fallbackLogo,
)

linkedData.length shouldEqual (2)
linkedData.head.`@type` shouldEqual ("NewsArticle")
linkedData(1).`@type` shouldEqual ("WebPage")
}

"LinkedData.forArticle" should "return recipe linkedData if there is any present" in {
val linkedData = LinkedData.forArticle(
article = testArticleWithRecipe,
baseURL = Configuration.dotcom.baseUrl,
fallbackLogo = Configuration.images.fallbackLogo,
)

linkedData.foreach(d => println(d))
linkedData.length shouldEqual (3)
linkedData.head.`@type` shouldEqual ("NewsArticle")
linkedData(1).`@type` shouldEqual ("WebPage")

val jsonString = Json.toJson(linkedData(2)).toString()
println(jsonString)
jsonString shouldEqual (expectedRecipeJson)
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
5 changes: 3 additions & 2 deletions project/Dependencies.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import sbt._
object Dependencies {
val identityLibVersion = "4.17"
val awsVersion = "1.12.638"
val capiVersion = "23.0.0"
val faciaVersion = "5.0.3"
val capiVersion = "25.0.0"
val faciaVersion = "5.0.6"
val dispatchVersion = "0.13.1"
val romeVersion = "1.0"
val jerseyVersion = "1.19.4"
Expand All @@ -33,6 +33,7 @@ object Dependencies {
val cssParser = "net.sourceforge.cssparser" % "cssparser" % "0.9.23"
val contentApiClient = "com.gu" %% "content-api-client" % capiVersion
val dfpAxis = "com.google.api-ads" % "dfp-axis" % "5.2.0"

val faciaFapiScalaClient = "com.gu" %% "fapi-client-play28" % faciaVersion
val identityCookie = "com.gu.identity" %% "identity-cookie" % identityLibVersion

Expand Down

0 comments on commit 213c2bc

Please sign in to comment.