Skip to content

Commit

Permalink
feat: Bunny.net cache purging, Origins specification for Cloudflare (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
JanCizmar committed Sep 23, 2024
1 parent ed55c32 commit ac048ac
Show file tree
Hide file tree
Showing 8 changed files with 236 additions and 22 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package io.tolgee.component.contentDelivery.cachePurging.bunny

import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurging
import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties
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
import java.net.URLEncoder

class BunnyContentDeliveryCachePurging(
private val config: ContentDeliveryBunnyProperties,
private val restTemplate: RestTemplate,
) : ContentDeliveryCachePurging {
override fun purgeForPaths(
contentDeliveryConfig: ContentDeliveryConfig,
paths: Set<String>,
) {
executePurgeRequest(contentDeliveryConfig)
}

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

private fun executePurgeRequest(contentDeliveryConfig: ContentDeliveryConfig) {
val entity: HttpEntity<String> = getHttpEntity()
val encodedPath = URLEncoder.encode("$prefix/${contentDeliveryConfig.slug}/*", Charsets.UTF_8)

val url = "https://api.bunny.net/purge?url=$encodedPath"

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

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

private fun getHttpEntity(): HttpEntity<String> {
val headers = getHeaders()
return HttpEntity(null, headers)
}

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

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

@Component
class BunnyContentDeliveryCachePurgingFactory(
private val restTemplate: RestTemplate,
) : ContentDeliveryCachePurgingFactory {
override fun create(config: Any): BunnyContentDeliveryCachePurging {
return BunnyContentDeliveryCachePurging(
config as ContentDeliveryBunnyProperties,
restTemplate,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,24 +22,7 @@ class CloudflareContentDeliveryCachePurging(
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>>>) {
private fun executePurgeRequest(bodies: List<Map<String, List<Map<String, Any>>>>) {
bodies.forEach { body ->
val entity: HttpEntity<String> = getHttpEntity(body)

Expand All @@ -59,7 +42,49 @@ class CloudflareContentDeliveryCachePurging(
}
}

private fun getHttpEntity(body: Map<String, List<String>>): HttpEntity<String> {
private fun getChunkedBody(
paths: Set<String>,
contentDeliveryConfig: ContentDeliveryConfig,
): List<Map<String, List<Map<String, Any>>>> {
return paths.flatMap {
getFileItems(contentDeliveryConfig, it)
}
.chunked(config.maxFilesPerRequest)
.map { fileItems ->
mapOf("files" to fileItems)
}
}

private fun getFileItems(
contentDeliveryConfig: ContentDeliveryConfig,
path: String,
): List<Map<String, Any>> {
return origins.map { origin ->
val map =
mutableMapOf<String, Any>(
"url" to "$prefix/${contentDeliveryConfig.slug}/$path",
)

if (origin != null) {
map["headers"] =
mapOf(
"Origin" to origin,
)
}

map
}
}

val origins by lazy {
config.origins?.split(",") ?: listOf(null)
}

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

private fun getHttpEntity(body: Any): HttpEntity<String> {
val headers = getHeaders()
val jsonBody = jacksonObjectMapper().writeValueAsString(body)
return HttpEntity(jsonBody, headers)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package io.tolgee.configuration.tolgee

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.bunny")
class ContentDeliveryBunnyProperties(
var apiKey: String? = null,
var urlPrefix: String? = null,
) : ContentDeliveryPurgingConfig {
override val enabled: Boolean
get() = !apiKey.isNullOrEmpty() && !urlPrefix.isNullOrEmpty()

override val contentDeliveryCachePurgingType: ContentDeliveryCachePurgingType
get() = ContentDeliveryCachePurgingType.BUNNY
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ class ContentDeliveryCloudflareProperties(
var apiKey: String? = null,
var urlPrefix: String? = null,
var zoneId: String? = null,
@DocProperty(
"If cache is filled with specific Origin header, it can be purged only if the purge request " +
"specifies the same Origin header. Here you can specify comma separated list of origins." +
"\n" +
"e.g. `https://example.com,https://example2.com`" +
"\n\n" +
"Read more in the Cloudflare " +
"[docs](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/).",
)
var origins: String? = null,
@DocProperty(
"Number of paths to purge in one request. " +
"(Cloudflare limit is 30 now, but it might be subject to change)",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package io.tolgee.model.contentDelivery

import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureContentDeliveryCachePurgingFactory
import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurgingFactory
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),
BUNNY(BunnyContentDeliveryCachePurgingFactory::class),
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package io.tolgee.unit.cachePurging

import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurging
import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
import io.tolgee.testing.assert
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 BunnyContentStorageConfigCachePurgingTest() {
@Test
fun `correctly purges`() {
val config =
ContentDeliveryBunnyProperties(
urlPrefix = "fake-url-prefix",
apiKey = "token",
)
val restTemplateMock: RestTemplate = mock()
val purging = BunnyContentDeliveryCachePurging(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 invocation = invocations.single()
assertUrl(invocation)
assertAuthorizationHeader(invocation)
}

private fun assertAuthorizationHeader(invocation: Invocation) {
val httpEntity = getHttpEntity(invocation)
val headers = httpEntity.headers
headers["AccessKey"].assert.isEqualTo(listOf("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.bunny.net/purge?url=fake-url-prefix%2Ffake-slug%2F*",
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ class CloudflareContentStorageConfigCachePurgingTest() {
urlPrefix = "fake-url-prefix",
maxFilesPerRequest = 10,
apiKey = "token",
origins = "fake-origin,fake-origin2",
)
val restTemplateMock: RestTemplate = mock()
val purging = CloudflareContentDeliveryCachePurging(config, restTemplateMock)
Expand All @@ -51,14 +52,33 @@ class CloudflareContentStorageConfigCachePurgingTest() {
assertFiles(firstInvocation) {
isArray
.hasSize(10)
.contains("fake-url-prefix/fake-slug/fake-path-1")
node("[0]") {
node("headers").isEqualTo(
mapOf(
"Origin" to "fake-origin",
),
)
node("url").isEqualTo("fake-url-prefix/fake-slug/fake-path-1")
}
node("[1]") {
node("headers").isEqualTo(
mapOf(
"Origin" to "fake-origin2",
),
)
}
}
assertUrl(firstInvocation)
assertAuthorizationHeader(firstInvocation)

assertFiles(invocations.toList()[1]) {
isArray.hasSize(5)
val invocationList = invocations.toList()
assertFiles(invocationList[1]) {
isArray.hasSize(10)
}
assertFiles(invocationList[2]) {
isArray.hasSize(10)
}
invocationList.assert.hasSize(3)
}

private fun assertFiles(
Expand Down

0 comments on commit ac048ac

Please sign in to comment.