Skip to content

Commit ac048ac

Browse files
authored
feat: Bunny.net cache purging, Origins specification for Cloudflare (tolgee#2471)
1 parent ed55c32 commit ac048ac

File tree

8 files changed

+236
-22
lines changed

8 files changed

+236
-22
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package io.tolgee.component.contentDelivery.cachePurging.bunny
2+
3+
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurging
4+
import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties
5+
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
6+
import org.springframework.http.HttpEntity
7+
import org.springframework.http.HttpHeaders
8+
import org.springframework.http.HttpMethod
9+
import org.springframework.http.MediaType
10+
import org.springframework.web.client.RestTemplate
11+
import java.net.URLEncoder
12+
13+
class BunnyContentDeliveryCachePurging(
14+
private val config: ContentDeliveryBunnyProperties,
15+
private val restTemplate: RestTemplate,
16+
) : ContentDeliveryCachePurging {
17+
override fun purgeForPaths(
18+
contentDeliveryConfig: ContentDeliveryConfig,
19+
paths: Set<String>,
20+
) {
21+
executePurgeRequest(contentDeliveryConfig)
22+
}
23+
24+
val prefix by lazy {
25+
config.urlPrefix?.removeSuffix("/") ?: ""
26+
}
27+
28+
private fun executePurgeRequest(contentDeliveryConfig: ContentDeliveryConfig) {
29+
val entity: HttpEntity<String> = getHttpEntity()
30+
val encodedPath = URLEncoder.encode("$prefix/${contentDeliveryConfig.slug}/*", Charsets.UTF_8)
31+
32+
val url = "https://api.bunny.net/purge?url=$encodedPath"
33+
34+
val response =
35+
restTemplate.exchange(
36+
url,
37+
HttpMethod.GET,
38+
entity,
39+
String::class.java,
40+
)
41+
42+
if (!response.statusCode.is2xxSuccessful) {
43+
throw IllegalStateException("Purging failed with status code ${response.statusCode}")
44+
}
45+
}
46+
47+
private fun getHttpEntity(): HttpEntity<String> {
48+
val headers = getHeaders()
49+
return HttpEntity(null, headers)
50+
}
51+
52+
private fun getHeaders(): HttpHeaders {
53+
val headers = HttpHeaders()
54+
headers.contentType = MediaType.APPLICATION_JSON
55+
headers.set("AccessKey", "${config.apiKey}")
56+
return headers
57+
}
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package io.tolgee.component.contentDelivery.cachePurging.bunny
2+
3+
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
4+
import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties
5+
import org.springframework.stereotype.Component
6+
import org.springframework.web.client.RestTemplate
7+
8+
@Component
9+
class BunnyContentDeliveryCachePurgingFactory(
10+
private val restTemplate: RestTemplate,
11+
) : ContentDeliveryCachePurgingFactory {
12+
override fun create(config: Any): BunnyContentDeliveryCachePurging {
13+
return BunnyContentDeliveryCachePurging(
14+
config as ContentDeliveryBunnyProperties,
15+
restTemplate,
16+
)
17+
}
18+
}

backend/data/src/main/kotlin/io/tolgee/component/contentDelivery/cachePurging/cloudflare/CloudflareContentDeliveryCachePurging.kt

Lines changed: 44 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -22,24 +22,7 @@ class CloudflareContentDeliveryCachePurging(
2222
executePurgeRequest(bodies)
2323
}
2424

25-
private fun getChunkedBody(
26-
paths: Set<String>,
27-
contentDeliveryConfig: ContentDeliveryConfig,
28-
): List<Map<String, List<String>>> {
29-
return paths.map {
30-
"$prefix/${contentDeliveryConfig.slug}/$it"
31-
}
32-
.chunked(config.maxFilesPerRequest)
33-
.map { urls ->
34-
mapOf("files" to urls)
35-
}
36-
}
37-
38-
val prefix by lazy {
39-
config.urlPrefix?.removeSuffix("/") ?: ""
40-
}
41-
42-
private fun executePurgeRequest(bodies: List<Map<String, List<String>>>) {
25+
private fun executePurgeRequest(bodies: List<Map<String, List<Map<String, Any>>>>) {
4326
bodies.forEach { body ->
4427
val entity: HttpEntity<String> = getHttpEntity(body)
4528

@@ -59,7 +42,49 @@ class CloudflareContentDeliveryCachePurging(
5942
}
6043
}
6144

62-
private fun getHttpEntity(body: Map<String, List<String>>): HttpEntity<String> {
45+
private fun getChunkedBody(
46+
paths: Set<String>,
47+
contentDeliveryConfig: ContentDeliveryConfig,
48+
): List<Map<String, List<Map<String, Any>>>> {
49+
return paths.flatMap {
50+
getFileItems(contentDeliveryConfig, it)
51+
}
52+
.chunked(config.maxFilesPerRequest)
53+
.map { fileItems ->
54+
mapOf("files" to fileItems)
55+
}
56+
}
57+
58+
private fun getFileItems(
59+
contentDeliveryConfig: ContentDeliveryConfig,
60+
path: String,
61+
): List<Map<String, Any>> {
62+
return origins.map { origin ->
63+
val map =
64+
mutableMapOf<String, Any>(
65+
"url" to "$prefix/${contentDeliveryConfig.slug}/$path",
66+
)
67+
68+
if (origin != null) {
69+
map["headers"] =
70+
mapOf(
71+
"Origin" to origin,
72+
)
73+
}
74+
75+
map
76+
}
77+
}
78+
79+
val origins by lazy {
80+
config.origins?.split(",") ?: listOf(null)
81+
}
82+
83+
val prefix by lazy {
84+
config.urlPrefix?.removeSuffix("/") ?: ""
85+
}
86+
87+
private fun getHttpEntity(body: Any): HttpEntity<String> {
6388
val headers = getHeaders()
6489
val jsonBody = jacksonObjectMapper().writeValueAsString(body)
6590
return HttpEntity(jsonBody, headers)
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package io.tolgee.configuration.tolgee
2+
3+
import io.tolgee.model.contentDelivery.ContentDeliveryCachePurgingType
4+
import io.tolgee.model.contentDelivery.ContentDeliveryPurgingConfig
5+
import org.springframework.boot.context.properties.ConfigurationProperties
6+
7+
@ConfigurationProperties(prefix = "tolgee.content-delivery.cache-purging.bunny")
8+
class ContentDeliveryBunnyProperties(
9+
var apiKey: String? = null,
10+
var urlPrefix: String? = null,
11+
) : ContentDeliveryPurgingConfig {
12+
override val enabled: Boolean
13+
get() = !apiKey.isNullOrEmpty() && !urlPrefix.isNullOrEmpty()
14+
15+
override val contentDeliveryCachePurgingType: ContentDeliveryCachePurgingType
16+
get() = ContentDeliveryCachePurgingType.BUNNY
17+
}

backend/data/src/main/kotlin/io/tolgee/configuration/tolgee/ContentDeliveryCloudflareProperties.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ class ContentDeliveryCloudflareProperties(
1010
var apiKey: String? = null,
1111
var urlPrefix: String? = null,
1212
var zoneId: String? = null,
13+
@DocProperty(
14+
"If cache is filled with specific Origin header, it can be purged only if the purge request " +
15+
"specifies the same Origin header. Here you can specify comma separated list of origins." +
16+
"\n" +
17+
"e.g. `https://example.com,https://example2.com`" +
18+
"\n\n" +
19+
"Read more in the Cloudflare " +
20+
"[docs](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-single-file/).",
21+
)
22+
var origins: String? = null,
1323
@DocProperty(
1424
"Number of paths to purge in one request. " +
1525
"(Cloudflare limit is 30 now, but it might be subject to change)",

backend/data/src/main/kotlin/io/tolgee/model/contentDelivery/ContentDeliveryCachePurgingType.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package io.tolgee.model.contentDelivery
22

33
import io.tolgee.component.contentDelivery.cachePurging.ContentDeliveryCachePurgingFactory
44
import io.tolgee.component.contentDelivery.cachePurging.azureFrontDoor.AzureContentDeliveryCachePurgingFactory
5+
import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurgingFactory
56
import io.tolgee.component.contentDelivery.cachePurging.cloudflare.CloudflareContentDeliveryCachePurgingFactory
67
import kotlin.reflect.KClass
78

89
enum class ContentDeliveryCachePurgingType(val factory: KClass<out ContentDeliveryCachePurgingFactory>) {
910
AZURE_FRONT_DOOR(AzureContentDeliveryCachePurgingFactory::class),
1011
CLOUDFLARE(CloudflareContentDeliveryCachePurgingFactory::class),
12+
BUNNY(BunnyContentDeliveryCachePurgingFactory::class),
1113
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package io.tolgee.unit.cachePurging
2+
3+
import io.tolgee.component.contentDelivery.cachePurging.bunny.BunnyContentDeliveryCachePurging
4+
import io.tolgee.configuration.tolgee.ContentDeliveryBunnyProperties
5+
import io.tolgee.model.contentDelivery.ContentDeliveryConfig
6+
import io.tolgee.testing.assert
7+
import org.junit.jupiter.api.Test
8+
import org.mockito.Mockito
9+
import org.mockito.invocation.Invocation
10+
import org.mockito.kotlin.any
11+
import org.mockito.kotlin.doAnswer
12+
import org.mockito.kotlin.eq
13+
import org.mockito.kotlin.mock
14+
import org.mockito.kotlin.whenever
15+
import org.springframework.http.HttpEntity
16+
import org.springframework.http.HttpMethod
17+
import org.springframework.http.HttpStatusCode
18+
import org.springframework.http.ResponseEntity
19+
import org.springframework.web.client.RestTemplate
20+
21+
class BunnyContentStorageConfigCachePurgingTest() {
22+
@Test
23+
fun `correctly purges`() {
24+
val config =
25+
ContentDeliveryBunnyProperties(
26+
urlPrefix = "fake-url-prefix",
27+
apiKey = "token",
28+
)
29+
val restTemplateMock: RestTemplate = mock()
30+
val purging = BunnyContentDeliveryCachePurging(config, restTemplateMock)
31+
val responseMock: ResponseEntity<*> = Mockito.mock(ResponseEntity::class.java)
32+
whenever(restTemplateMock.exchange(any<String>(), any<HttpMethod>(), any(), eq(String::class.java))).doAnswer {
33+
responseMock as ResponseEntity<String>
34+
}
35+
whenever(responseMock.statusCode).thenReturn(HttpStatusCode.valueOf(200))
36+
val contentDeliveryConfig = mock<ContentDeliveryConfig>()
37+
whenever(contentDeliveryConfig.slug).thenReturn("fake-slug")
38+
39+
purging.purgeForPaths(
40+
contentDeliveryConfig = contentDeliveryConfig,
41+
paths = (1..15).map { "fake-path-$it" }.toSet(),
42+
)
43+
44+
val invocations = Mockito.mockingDetails(restTemplateMock).invocations
45+
val invocation = invocations.single()
46+
assertUrl(invocation)
47+
assertAuthorizationHeader(invocation)
48+
}
49+
50+
private fun assertAuthorizationHeader(invocation: Invocation) {
51+
val httpEntity = getHttpEntity(invocation)
52+
val headers = httpEntity.headers
53+
headers["AccessKey"].assert.isEqualTo(listOf("token"))
54+
}
55+
56+
private fun getHttpEntity(invocation: Invocation) = invocation.arguments[2] as HttpEntity<*>
57+
58+
private fun assertUrl(invocation: Invocation) {
59+
val url = invocation.arguments[0]
60+
url.assert.isEqualTo(
61+
"https://api.bunny.net/purge?url=fake-url-prefix%2Ffake-slug%2F*",
62+
)
63+
}
64+
}

backend/data/src/test/kotlin/io/tolgee/unit/cachePurging/CloudflareContentStorageConfigCachePurgingTest.kt

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class CloudflareContentStorageConfigCachePurgingTest() {
3030
urlPrefix = "fake-url-prefix",
3131
maxFilesPerRequest = 10,
3232
apiKey = "token",
33+
origins = "fake-origin,fake-origin2",
3334
)
3435
val restTemplateMock: RestTemplate = mock()
3536
val purging = CloudflareContentDeliveryCachePurging(config, restTemplateMock)
@@ -51,14 +52,33 @@ class CloudflareContentStorageConfigCachePurgingTest() {
5152
assertFiles(firstInvocation) {
5253
isArray
5354
.hasSize(10)
54-
.contains("fake-url-prefix/fake-slug/fake-path-1")
55+
node("[0]") {
56+
node("headers").isEqualTo(
57+
mapOf(
58+
"Origin" to "fake-origin",
59+
),
60+
)
61+
node("url").isEqualTo("fake-url-prefix/fake-slug/fake-path-1")
62+
}
63+
node("[1]") {
64+
node("headers").isEqualTo(
65+
mapOf(
66+
"Origin" to "fake-origin2",
67+
),
68+
)
69+
}
5570
}
5671
assertUrl(firstInvocation)
5772
assertAuthorizationHeader(firstInvocation)
5873

59-
assertFiles(invocations.toList()[1]) {
60-
isArray.hasSize(5)
74+
val invocationList = invocations.toList()
75+
assertFiles(invocationList[1]) {
76+
isArray.hasSize(10)
77+
}
78+
assertFiles(invocationList[2]) {
79+
isArray.hasSize(10)
6180
}
81+
invocationList.assert.hasSize(3)
6282
}
6383

6484
private fun assertFiles(

0 commit comments

Comments
 (0)