common/app/contentapi/ContentApiClient.scala (163 lines of code) (raw):

package contentapi import java.util.concurrent.TimeUnit import org.apache.pekko.actor.{ActorSystem => PekkoActorSystem} import com.github.nscala_time.time.Implicits._ import com.gu.contentapi.client.model._ import com.gu.contentapi.client.model.v1.{Edition => _, _} import com.gu.contentapi.client.{ BackoffStrategy, Retryable, RetryableContentApiClient, ScheduledExecutor, ContentApiClient => CapiContentApiClient, } import common._ import concurrent.CircuitBreakerRegistry import conf.Configuration import conf.Configuration.contentApi import conf.switches.Switches.CircuitBreakerSwitch import scala.concurrent.duration.{Duration, MILLISECONDS} import scala.concurrent.{ExecutionContext, Future} import scala.util.Try object QueryDefaults { // NOTE - do NOT add body to this list val trailFieldsList = List[String]( "byline", "headline", "trail-text", "liveBloggingNow", "thumbnail", "hasStoryPackage", "wordcount", "shortUrl", "commentable", "commentCloseDate", "starRating", "productionOffice", ) val mainField = List[String]("main") val trailFields = trailFieldsList.mkString(",") // main field is needed for Main Media Atom data required by InlineYouTubeDisplayElement val trailFieldsWithMain: String = (trailFieldsList ::: mainField).mkString(",") val references = List( "pa-football-competition", "pa-football-team", "witness-assignment", "esa-cricket-match", ).mkString(",") val leadContentMaxAge = 1.day object FaciaDefaults { val tag = "tag=type/gallery|type/article|type/video|type/sudoku" val editorsPicks = "show-editors-picks=true" val showInlineFields = s"show-fields=$trailFields" val showFields = "trailText,headline,shortUrl,liveBloggingNow,thumbnail,commentable,commentCloseDate,shouldHideAdverts,lastModified,byline,standfirst,starRating,showInRelatedContent,internalContentCode,internalPageCode" val showFieldsWithBody = showFields + ",body" val all = Seq(tag, editorsPicks, showInlineFields, showFields) def generateContentApiQuery(id: String): String = "%s?&%s" .format(id, all.mkString("", "&", "")) } } trait ApiQueryDefaults extends GuLogging { def item(id: String): ItemQuery = CapiContentApiClient.item(id) def item(id: String, edition: Edition): ItemQuery = item(id, EditionIdCAPIMapper.mapEditionId(edition)) // Strip unnecessary leading slash in path, as this affects signing of IAM requests def item(id: String, edition: String): ItemQuery = item(id.stripPrefix("/")) // common fields that we use across most queries. .edition(edition) .showSection(true) .showTags("all") .showFields(QueryDefaults.trailFields) .showElements("all") .showReferences(QueryDefaults.references) .showPackages(true) .showRights("syndicatable") .showAtoms("media") // common fields that we use across most queries. def search(): SearchQuery = CapiContentApiClient.search .showTags("all") .showReferences(QueryDefaults.references) .showFields(QueryDefaults.trailFieldsWithMain) .showElements("all") .showAtoms("media") } // This trait extends ContentApiClient with Cloudwatch metrics that monitor // the average response time, and the number of timeouts, from Content Api. trait MonitoredContentApiClientLogic extends CapiContentApiClient with ApiQueryDefaults with GuLogging { val httpClient: HttpClient def get(url: String, headers: Map[String, String])(implicit executionContext: ExecutionContext, ): Future[HttpResponse] = { val futureContent = httpClient.GET(url, headers) map { response: Response => HttpResponse(response.body, response.status, response.statusText) } futureContent.failed.foreach { t => val tryDecodedUrl: String = Try(java.net.URLDecoder.decode(url, "UTF-8")).getOrElse(url) log.error(s"$t: $tryDecodedUrl") } futureContent } } final case class CircuitBreakingContentApiClient( override val httpClient: HttpClient, override val targetUrl: String, apiKey: String, )(implicit executionContext: ExecutionContext) extends MonitoredContentApiClientLogic with RetryableContentApiClient { override implicit val executor: ScheduledExecutor = ScheduledExecutor() val retryDuration = Duration(250L, TimeUnit.MILLISECONDS) val retryAttempts = 3 override val backoffStrategy: Retryable = BackoffStrategy.constantStrategy(retryDuration, retryAttempts) private[this] val circuitBreaker = CircuitBreakerRegistry.withConfig( name = "content-api-client", system = PekkoActorSystem("content-api-client-circuit-breaker"), maxFailures = contentApi.circuitBreakerErrorThreshold, callTimeout = contentApi.timeout + Duration .create(400, MILLISECONDS), // +400 to differentiate between circuit breaker and capi timeouts resetTimeout = contentApi.circuitBreakerResetTimeout, ) override def get(url: String, headers: Map[String, String])(implicit executionContext: ExecutionContext, ): Future[HttpResponse] = { if (CircuitBreakerSwitch.isSwitchedOn) { circuitBreaker.withCircuitBreaker(super.get(url, headers)(executionContext)) } else { super.get(url, headers) } } } class ContentApiClient(httpClient: HttpClient)(implicit executionContext: ExecutionContext) extends ApiQueryDefaults { // Public val for test. val thriftClient = CircuitBreakingContentApiClient( httpClient = httpClient, targetUrl = contentApi.contentApiHost, apiKey = contentApi.key.getOrElse(""), ) private def getClient: CircuitBreakingContentApiClient = { thriftClient } def tags: TagsQuery = CapiContentApiClient.tags def sections: SectionsQuery = CapiContentApiClient.sections def editions: EditionsQuery = CapiContentApiClient.editions def getResponse(itemQuery: ItemQuery): Future[ItemResponse] = getClient.getResponse(itemQuery) def getResponse(searchQuery: SearchQuery): Future[SearchResponse] = getClient.getResponse(searchQuery) def getResponse(tagsQuery: TagsQuery): Future[TagsResponse] = getClient.getResponse(tagsQuery) def getResponse(sectionsQuery: SectionsQuery): Future[SectionsResponse] = getClient.getResponse(sectionsQuery) def getResponse(editionsQuery: EditionsQuery): Future[EditionsResponse] = getClient.getResponse(editionsQuery) def getResponse(atomUsageQuery: AtomUsageQuery): Future[AtomUsageResponse] = getClient.getResponse(atomUsageQuery) } // The Admin server uses this PreviewContentApi to check the preview environment. // The Preview server uses the standard ContentApiClient object, configured with preview settings. class PreviewContentApi(httpClient: HttpClient)(implicit executionContext: ExecutionContext) extends ContentApiClient(httpClient) { override val thriftClient = CircuitBreakingContentApiClient( httpClient = httpClient, targetUrl = Configuration.contentApi.previewHost.getOrElse(Configuration.contentApi.contentApiHost), apiKey = contentApi.key.getOrElse(""), ) } object EditionIdCAPIMapper { def mapEditionId(edition: Edition): String = { edition match { case editions.Uk => "UK" case editions.Us => "US" case editions.Au => "AU" case editions.International => "INTERNATIONAL" case editions.Europe => "EUROPE" } } }