Skip to content

Commit

Permalink
feat: Enable Cloudflare cache purging (#2461)
Browse files Browse the repository at this point in the history
  • Loading branch information
JanCizmar authored Sep 12, 2024
1 parent cd198e1 commit bcb7ec8
Show file tree
Hide file tree
Showing 14 changed files with 233 additions and 22 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class AutomationIntegrationTest : ProjectAuthControllerTest("/v2/projects/") {
fileStorageMock = mock()
doReturn(fileStorageMock).whenever(contentDeliveryFileStorageProvider).getContentStorageWithDefaultClient()
purgingMock = mock()
doReturn(purgingMock).whenever(contentDeliveryCachePurgingProvider).defaultPurging
doReturn(listOf(purgingMock)).whenever(contentDeliveryCachePurgingProvider).purgings

// wait for the first invocation happening because of test data saving, then clear invocations
Thread.sleep(1000)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@ class ContentDeliveryUploader(
) {
val isDefaultStorage = contentDeliveryConfig.contentStorage == null
if (isDefaultStorage) {
contentDeliveryCachePurgingProvider.defaultPurging?.purgeForPaths(contentDeliveryConfig, paths)
contentDeliveryCachePurgingProvider.purgings.forEach {
it.purgeForPaths(contentDeliveryConfig, paths)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,22 +9,16 @@ class ContentDeliveryCachePurgingProvider(
private val applicationContext: AbstractApplicationContext,
private val configs: List<ContentDeliveryPurgingConfig>,
) {
val defaultPurging by lazy {
val purgings by lazy {
getDefaultFactory()
}

private fun getDefaultFactory(): ContentDeliveryCachePurging? {
val purgings =
configs.mapNotNull {
if (!it.enabled) {
return@mapNotNull null
}
applicationContext.getBean(it.contentDeliveryCachePurgingType.factory.java).create(it)
private fun getDefaultFactory(): List<ContentDeliveryCachePurging> {
return configs.mapNotNull {
if (!it.enabled) {
return@mapNotNull null
}
if (purgings.size > 1) {
throw RuntimeException("Exactly one content delivery purging must be set")
applicationContext.getBean(it.contentDeliveryCachePurgingType.factory.java).create(it)
}

return purgings.firstOrNull()
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package io.tolgee.component.contentDelivery.cachePurging
package io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor

import com.azure.core.credential.TokenRequestContext
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurging
import io.tolgee.model.contentDelivery.AzureFrontDoorConfig
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import org.springframework.http.HttpEntity
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package io.tolgee.component.contentDelivery.cachePurging
package io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor

import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
import io.tolgee.model.contentDelivery.AzureFrontDoorConfig
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package io.tolgee.component.contentDelivery.cachePurging
package io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor

import com.azure.identity.ClientSecretCredential
import com.azure.identity.ClientSecretCredentialBuilder
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package io.tolgee.component.contentDelivery.cachePurging.cloudflare

import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurging
import io.tolgee.configuration.tolgee.ContentDeliveryCloudflareProperties
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.http.MediaType
import org.springframework.web.client.RestTemplate

class CloudflareContentDeliveryCachePurging(
private val config: ContentDeliveryCloudflareProperties,
private val restTemplate: RestTemplate,
) : ContentDeliveryCachePurging {
override fun purgeForPaths(
contentDeliveryConfig: ContentDeliveryConfig,
paths: Set<String>,
) {
val bodies = getChunkedBody(paths, contentDeliveryConfig)
executePurgeRequest(bodies)
}

private fun getChunkedBody(
paths: Set<String>,
contentDeliveryConfig: ContentDeliveryConfig,
): List<Map<String, List<String>>> {
return paths.map {
"$prefix/${contentDeliveryConfig.slug}/$it"
}
.chunked(config.maxFilesPerRequest)
.map { urls ->
mapOf("files" to urls)
}
}

val prefix by lazy {
config.urlPrefix?.removeSuffix("/") ?: ""
}

private fun executePurgeRequest(bodies: List<Map<String, List<String>>>) {
bodies.forEach { body ->
val entity: HttpEntity<String> = getHttpEntity(body)

val url = "https://api.cloudflare.com/client/v4/zones/${config.zoneId}/purge_cache"

val response =
restTemplate.exchange(
url,
HttpMethod.POST,
entity,
String::class.java,
)

if (!response.statusCode.is2xxSuccessful) {
throw IllegalStateException("Purging failed with status code ${response.statusCode}")
}
}
}

private fun getHttpEntity(body: Map<String, List<String>>): HttpEntity<String> {
val headers = getHeaders()
val jsonBody = jacksonObjectMapper().writeValueAsString(body)
return HttpEntity(jsonBody, headers)
}

private fun getHeaders(): HttpHeaders {
val headers = HttpHeaders()
headers.contentType = MediaType.APPLICATION_JSON
headers.set("Authorization", "Bearer ${config.apiKey}")
return headers
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.tolgee.component.contentDelivery.cachePurging.cloudflare

import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
import io.tolgee.configuration.tolgee.ContentDeliveryCloudflareProperties
import org.springframework.stereotype.Component
import org.springframework.web.client.RestTemplate

@Component
class CloudflareContentDeliveryCachePurgingFactory(
private val restTemplate: RestTemplate,
) : ContentDeliveryCachePurgingFactory {
override fun create(config: Any): CloudflareContentDeliveryCachePurging {
return CloudflareContentDeliveryCachePurging(
config as ContentDeliveryCloudflareProperties,
restTemplate,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "tolgee.content-delivery.cache-purging")
class ContentDeliveryCachePurgingProperties {
var azureFrontDoor: ContentDeliveryAzureFrontDoorProperties = ContentDeliveryAzureFrontDoorProperties()
var azureFrontDoor = ContentDeliveryAzureFrontDoorProperties()
var cloudflare = ContentDeliveryCloudflareProperties()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package io.tolgee.configuration.tolgee

import io.tolgee.configuration.annotations.DocProperty
import io.tolgee.model.contentDelivery.ContentDeliveryCachePurgingType
import io.tolgee.model.contentDelivery.ContentDeliveryPurgingConfig
import org.springframework.boot.context.properties.ConfigurationProperties

@ConfigurationProperties(prefix = "tolgee.content-delivery.cache-purging.cloudflare")
class ContentDeliveryCloudflareProperties(
var apiKey: String? = null,
var urlPrefix: String? = null,
var zoneId: String? = null,
@DocProperty(
"Number of paths to purge in one request. " +
"(Cloudflare limit is 30 now, but it might be subject to change)",
)
var maxFilesPerRequest: Int = 30,
) : ContentDeliveryPurgingConfig {
override val enabled: Boolean
get() = !apiKey.isNullOrEmpty() && !urlPrefix.isNullOrEmpty() && !zoneId.isNullOrEmpty()

override val contentDeliveryCachePurgingType: ContentDeliveryCachePurgingType
get() = ContentDeliveryCachePurgingType.CLOUDFLARE
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ class ContentDeliveryProperties {
@DocProperty(description = "Configuration of the storage. You have to configure exactly one storage.")
var storage: ContentStorageProperties = ContentStorageProperties()

@DocProperty(hidden = true)
@DocProperty(
description =
"Several services can be used as cache. Tolgee is able to purge the cache when " +
"new files are published when this configuration is set.",
)
var cachePurging: ContentDeliveryCachePurgingProperties = ContentDeliveryCachePurgingProperties()
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package io.tolgee.model.contentDelivery

import io.tolgee.component.contentDelivery.cachePurging.AzureContentDeliveryCachePurgingFactory
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureContentDeliveryCachePurgingFactory
import io.tolgee.component.contentDelivery.cachePurging.cloudflare.CloudflareContentDeliveryCachePurgingFactory
import kotlin.reflect.KClass

enum class ContentDeliveryCachePurgingType(val factory: KClass<out ContentDeliveryCachePurgingFactory>) {
AZURE_FRONT_DOOR(AzureContentDeliveryCachePurgingFactory::class),
CLOUDFLARE(CloudflareContentDeliveryCachePurgingFactory::class),
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
package io.tolgee.unit.cachePurging

import com.azure.identity.ClientSecretCredential
import io.tolgee.component.contentDelivery.cachePurging.AzureContentDeliveryCachePurging
import io.tolgee.component.contentDelivery.cachePurging.AzureCredentialProvider
import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureContentDeliveryCachePurging
import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureCredentialProvider
import io.tolgee.model.contentDelivery.AzureFrontDoorConfig
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import io.tolgee.testing.assert
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package io.tolgee.unit.cachePurging

import io.tolgee.component.contentDelivery.cachePurging.cloudflare.CloudflareContentDeliveryCachePurging
import io.tolgee.configuration.tolgee.ContentDeliveryCloudflareProperties
import io.tolgee.fixtures.node
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import io.tolgee.testing.assert
import net.javacrumbs.jsonunit.assertj.JsonAssert
import net.javacrumbs.jsonunit.assertj.assertThatJson
import org.junit.jupiter.api.Test
import org.mockito.Mockito
import org.mockito.invocation.Invocation
import org.mockito.kotlin.any
import org.mockito.kotlin.doAnswer
import org.mockito.kotlin.eq
import org.mockito.kotlin.mock
import org.mockito.kotlin.whenever
import org.springframework.http.HttpEntity
import org.springframework.http.HttpMethod
import org.springframework.http.HttpStatusCode
import org.springframework.http.ResponseEntity
import org.springframework.web.client.RestTemplate

class CloudflareContentStorageConfigCachePurgingTest() {
@Test
fun `correctly purges`() {
val config =
ContentDeliveryCloudflareProperties(
zoneId = "fake-zone-id",
urlPrefix = "fake-url-prefix",
maxFilesPerRequest = 10,
apiKey = "token",
)
val restTemplateMock: RestTemplate = mock()
val purging = CloudflareContentDeliveryCachePurging(config, restTemplateMock)
val responseMock: ResponseEntity<*> = Mockito.mock(ResponseEntity::class.java)
whenever(restTemplateMock.exchange(any<String>(), any<HttpMethod>(), any(), eq(String::class.java))).doAnswer {
responseMock as ResponseEntity<String>
}
whenever(responseMock.statusCode).thenReturn(HttpStatusCode.valueOf(200))
val contentDeliveryConfig = mock<ContentDeliveryConfig>()
whenever(contentDeliveryConfig.slug).thenReturn("fake-slug")

purging.purgeForPaths(
contentDeliveryConfig = contentDeliveryConfig,
paths = (1..15).map { "fake-path-$it" }.toSet(),
)

val invocations = Mockito.mockingDetails(restTemplateMock).invocations
val firstInvocation = invocations.first()
assertFiles(firstInvocation) {
isArray
.hasSize(10)
.contains("fake-url-prefix/fake-slug/fake-path-1")
}
assertUrl(firstInvocation)
assertAuthorizationHeader(firstInvocation)

assertFiles(invocations.toList()[1]) {
isArray.hasSize(5)
}
}

private fun assertFiles(
invocation: Invocation,
fn: JsonAssert.() -> Unit,
) {
val httpEntity = getHttpEntity(invocation)
assertThatJson(httpEntity.body) {
node("files") {
fn()
}
}
}

private fun assertAuthorizationHeader(invocation: Invocation) {
val httpEntity = getHttpEntity(invocation)
val headers = httpEntity.headers
headers["Authorization"].assert.isEqualTo(listOf("Bearer token"))
}

private fun getHttpEntity(invocation: Invocation) = invocation.arguments[2] as HttpEntity<*>

private fun assertUrl(invocation: Invocation) {
val url = invocation.arguments[0]
url.assert.isEqualTo(
"https://api.cloudflare.com/client/v4/zones/fake-zone-id/purge_cache",
)
}
}

0 comments on commit bcb7ec8

Please sign in to comment.