diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72ec821..339d09b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,7 @@ jobs: run: | gcloud run deploy ${{ vars.SERVICE_NAME }} \ --image ${{ vars.CONTAINER_IMAGE }}:${{ env.SHA_SHORT }} \ + --update-secrets=KS_REDIS_REST_URL=redis-rest-url:latest,KS_REDIS_REST_TOKEN=redis-rest-token:latest \ --region ${{ secrets.GCP_REGION }} \ --cpu ${{ vars.CONTAINER_CPU }} \ --memory ${{ vars.CONTAINER_MEMORY }} \ diff --git a/build.gradle.kts b/build.gradle.kts index 4a21f22..5e93cf2 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ dependencyManagement { imports { mavenBom(libs.dgs.bom.get().toString()) } + applyMavenExclusions(false) } graalvmNative { @@ -40,6 +41,18 @@ graalvmNative { } } +tasks.bootRun { + environment( + envVar("KS_REDIS_REST_URL"), + envVar("KS_REDIS_REST_TOKEN"), + ) +} + +fun envVar(name: String): Pair { + return name to (providers.environmentVariable(name).orElse(providers.gradleProperty(name)).orNull + ?: error("Missing environment variable or Gradle property: $name")) +} + tasks.withType().configureEach { packageName = "io.github.reactivecircus.kstreamlined.backend.schema.generated" } @@ -51,9 +64,7 @@ kotlin { } compilerOptions { freeCompilerArgs.addAll( - "-opt-in=kotlin.RequiresOptIn", "-Xjsr305=strict", - "-Xcontext-receivers", ) } } @@ -89,8 +100,9 @@ dependencies { implementation(libs.spring.boot.starter) implementation(libs.dgs.starter) implementation(libs.ktor.client.core) - implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.contentNegotiation) + implementation(libs.ktor.serialization.json) implementation(libs.ktor.serialization.xml) implementation(libs.apacheCommonsText) implementation(libs.apacheCommonsLang3) @@ -100,7 +112,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.xalan) - testImplementation(libs.spring.boot.starter.test) testImplementation(kotlin("test")) + testImplementation(libs.spring.boot.starter.test) testImplementation(libs.ktor.client.mock) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cb1563..a279b57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -32,9 +32,10 @@ detektFormatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", v spring-boot-starter = { module = "org.springframework.boot:spring-boot-starter-webflux" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } -ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" } +ktor-serialization-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-xml = { module = "io.ktor:ktor-serialization-kotlinx-xml", version.ref = "ktor" } dgs-bom = { module = "com.netflix.graphql.dgs:graphql-dgs-platform-dependencies", version.ref = "dgsBom" } dgs-starter = { module = "com.netflix.graphql.dgs:graphql-dgs-spring-graphql-starter"} diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt index d294857..731dd7f 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt @@ -1,56 +1,79 @@ package io.github.reactivecircus.kstreamlined.backend -import io.github.reactivecircus.kstreamlined.backend.client.ClientConfigs -import io.github.reactivecircus.kstreamlined.backend.client.FeedClient -import io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient -import io.github.reactivecircus.kstreamlined.backend.client.RealFeedClient -import io.github.reactivecircus.kstreamlined.backend.client.RealKotlinWeeklyIssueClient +import io.github.reactivecircus.kstreamlined.backend.datasource.DataLoader +import io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSourceConfig +import io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.RealFeedDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.RealKotlinWeeklyIssueDataSource +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.engine.cio.CIO +import io.ktor.client.engine.okhttp.OkHttp import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes @Configuration class KSConfiguration { @Bean - fun feedClient( + fun feedDataSource( engine: HttpClientEngine, - clientConfigs: ClientConfigs, - ): FeedClient { - return RealFeedClient( + dataSourceConfig: FeedDataSourceConfig, + redisClient: RedisClient, + ): FeedDataSource { + return RealFeedDataSource( engine = engine, - clientConfigs = clientConfigs, + dataSourceConfig = dataSourceConfig, + cacheConfig = DataLoader.CacheConfig( + localExpiry = 10.minutes, + remoteExpiry = 1.hours, + ), + redisClient = redisClient, ) } @Bean - fun kotlinWeeklyIssueClient( + fun feedDataSourceConfig( + @Value("\${ks.kotlin-blog-feed-url}") kotlinBlogFeedUrl: String, + @Value("\${ks.kotlin-youtube-feed-url}") kotlinYouTubeFeedUrl: String, + @Value("\${ks.talking-kotlin-feed-url}") talkingKotlinFeedUrl: String, + @Value("\${ks.kotlin-weekly-feed-url}") kotlinWeeklyFeedUrl: String, + ): FeedDataSourceConfig { + return FeedDataSourceConfig( + kotlinBlogFeedUrl = kotlinBlogFeedUrl, + kotlinYouTubeFeedUrl = kotlinYouTubeFeedUrl, + talkingKotlinFeedUrl = talkingKotlinFeedUrl, + kotlinWeeklyFeedUrl = kotlinWeeklyFeedUrl, + ) + } + + @Bean + fun kotlinWeeklyIssueDataSource( engine: HttpClientEngine - ): KotlinWeeklyIssueClient { - return RealKotlinWeeklyIssueClient( + ): KotlinWeeklyIssueDataSource { + return RealKotlinWeeklyIssueDataSource( engine = engine, ) } @Bean fun httpClientEngine(): HttpClientEngine { - return CIO.create() + return OkHttp.create() } @Bean - fun clientConfigs( - @Value("\${ks.kotlin-blog-feed-url}") kotlinBlogFeedUrl: String, - @Value("\${ks.kotlin-youtube-feed-url}") kotlinYouTubeFeedUrl: String, - @Value("\${ks.talking-kotlin-feed-url}") talkingKotlinFeedUrl: String, - @Value("\${ks.kotlin-weekly-feed-url}") kotlinWeeklyFeedUrl: String, - ): ClientConfigs { - return ClientConfigs( - kotlinBlogFeedUrl = kotlinBlogFeedUrl, - kotlinYouTubeFeedUrl = kotlinYouTubeFeedUrl, - talkingKotlinFeedUrl = talkingKotlinFeedUrl, - kotlinWeeklyFeedUrl = kotlinWeeklyFeedUrl, + fun redisClient( + engine: HttpClientEngine, + @Value("\${KS_REDIS_REST_URL}") redisUrl: String, + @Value("\${KS_REDIS_REST_TOKEN}") redisToken: String, + ): RedisClient { + return RedisClient( + engine = engine, + url = redisUrl, + token = redisToken, ) } } diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/ClientConfigs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/ClientConfigs.kt deleted file mode 100644 index 490f296..0000000 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/ClientConfigs.kt +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.reactivecircus.kstreamlined.backend.client - -class ClientConfigs( - val kotlinBlogFeedUrl: String, - val kotlinYouTubeFeedUrl: String, - val talkingKotlinFeedUrl: String, - val kotlinWeeklyFeedUrl: String, -) diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FeedClient.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FeedClient.kt deleted file mode 100644 index 254105a..0000000 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FeedClient.kt +++ /dev/null @@ -1,118 +0,0 @@ -package io.github.reactivecircus.kstreamlined.backend.client - -import com.github.benmanes.caffeine.cache.Cache -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogRss -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyRss -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeRss -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinRss -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.HttpClientEngine -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.plugins.contentnegotiation.ContentNegotiation -import io.ktor.client.request.get -import io.ktor.client.statement.bodyAsText -import io.ktor.http.ContentType -import io.ktor.serialization.kotlinx.xml.DefaultXml -import io.ktor.serialization.kotlinx.xml.xml -import kotlinx.serialization.decodeFromString -import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi -import nl.adaptivity.xmlutil.serialization.DefaultXmlSerializationPolicy -import nl.adaptivity.xmlutil.serialization.XmlConfig -import org.apache.commons.text.StringEscapeUtils - -interface FeedClient { - - context(CacheContext) - suspend fun loadKotlinBlogFeed(): List - - context(CacheContext) - suspend fun loadKotlinYouTubeFeed(): List - - context(CacheContext) - suspend fun loadTalkingKotlinFeed(): List - - context(CacheContext) - suspend fun loadKotlinWeeklyFeed(): List -} - -interface CacheContext { - val cache: Cache> -} - -class RealFeedClient( - engine: HttpClientEngine, - private val clientConfigs: ClientConfigs -) : FeedClient { - - @OptIn(ExperimentalXmlUtilApi::class) - private val httpClient = HttpClient(engine) { - expectSuccess = true - install(ContentNegotiation) { - val format = DefaultXml.copy { - policy = DefaultXmlSerializationPolicy( - DefaultXmlSerializationPolicy.Builder().apply { - pedantic = false - unknownChildHandler = XmlConfig.IGNORING_UNKNOWN_CHILD_HANDLER - }.build() - ) - } - - xml(format, ContentType.Application.Rss) - xml(format, ContentType.Application.Xml) - xml(format, ContentType.Text.Xml) - } - install(HttpTimeout) { - connectTimeoutMillis = HttpTimeoutMillis - requestTimeoutMillis = HttpTimeoutMillis - } - } - - context(CacheContext) - override suspend fun loadKotlinBlogFeed(): List = getFromCacheOrFetch { - httpClient.get(clientConfigs.kotlinBlogFeedUrl).body().channel.items.map { - it.copy( - description = StringEscapeUtils.unescapeXml(it.description).trim() - ) - } - } - - context(CacheContext) - override suspend fun loadKotlinYouTubeFeed(): List = getFromCacheOrFetch { - httpClient.get(clientConfigs.kotlinYouTubeFeedUrl).bodyAsText().let { - DefaultXml.decodeFromString(it.replace("&(?!.{2,4};)".toRegex(), "&")).entries - } - } - - context(CacheContext) - override suspend fun loadTalkingKotlinFeed(): List = getFromCacheOrFetch { - httpClient.get(clientConfigs.talkingKotlinFeedUrl).body().channel.items - .take(TalkingKotlinFeedSize) - .map { - it.copy( - summary = StringEscapeUtils.unescapeXml(it.summary).trim() - ) - } - } - - context(CacheContext) - override suspend fun loadKotlinWeeklyFeed(): List = getFromCacheOrFetch { - httpClient.get(clientConfigs.kotlinWeeklyFeedUrl).body().channel.items - } - - companion object { - private const val HttpTimeoutMillis = 30_000L - private const val TalkingKotlinFeedSize = 10 - } -} - -context(CacheContext) -private suspend fun getFromCacheOrFetch(fetch: suspend () -> List): List { - return cache.getIfPresent(Unit) ?: fetch().also { - cache.put(Unit, it) - } -} diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcher.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcher.kt index 6854ead..bac8e89 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcher.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcher.kt @@ -1,21 +1,14 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher -import com.github.benmanes.caffeine.cache.Cache -import com.github.benmanes.caffeine.cache.Caffeine import com.netflix.graphql.dgs.DgsComponent import com.netflix.graphql.dgs.DgsQuery import com.netflix.graphql.dgs.DgsTypeResolver import com.netflix.graphql.dgs.InputArgument -import io.github.reactivecircus.kstreamlined.backend.client.CacheContext -import io.github.reactivecircus.kstreamlined.backend.client.FeedClient -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinBlogEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinWeeklyEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinYouTubeEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toTalkingKotlinEntry +import io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource import io.github.reactivecircus.kstreamlined.backend.schema.generated.DgsConstants import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedEntry import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedSourceKey @@ -28,42 +21,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import java.time.Duration @DgsComponent class FeedEntryDataFetcher( - private val client: FeedClient, + private val dataSource: FeedDataSource, ) { private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO - private val kotlinBlogCacheContext = object : CacheContext { - override val cache: Cache> = Caffeine - .newBuilder() - .expireAfterWrite(Duration.ofHours(1)) - .build() - } - - private val kotlinYouTubeCacheContext = object : CacheContext { - override val cache: Cache> = Caffeine - .newBuilder() - .expireAfterWrite(Duration.ofHours(1)) - .build() - } - - private val talkingKotlinCacheContext = object : CacheContext { - override val cache: Cache> = Caffeine - .newBuilder() - .expireAfterWrite(Duration.ofHours(1)) - .build() - } - - private val kotlinWeeklyCacheContext = object : CacheContext { - override val cache: Cache> = Caffeine - .newBuilder() - .expireAfterWrite(Duration.ofHours(1)) - .build() - } - @DgsQuery(field = DgsConstants.QUERY.FeedEntries) suspend fun feedEntries(@InputArgument filters: List?): List = coroutineScope { FeedSourceKey.entries.filter { @@ -71,20 +35,20 @@ class FeedEntryDataFetcher( }.map { source -> async(coroutineDispatcher) { when (source) { - FeedSourceKey.KOTLIN_BLOG -> with(kotlinBlogCacheContext) { - client.loadKotlinBlogFeed().map { it.toKotlinBlogEntry() } + FeedSourceKey.KOTLIN_BLOG -> { + dataSource.loadKotlinBlogFeed().map { it.toKotlinBlogEntry() } } - FeedSourceKey.KOTLIN_YOUTUBE_CHANNEL -> with(kotlinYouTubeCacheContext) { - client.loadKotlinYouTubeFeed().map { it.toKotlinYouTubeEntry() } + FeedSourceKey.KOTLIN_YOUTUBE_CHANNEL -> { + dataSource.loadKotlinYouTubeFeed().map { it.toKotlinYouTubeEntry() } } - FeedSourceKey.TALKING_KOTLIN_PODCAST -> with(talkingKotlinCacheContext) { - client.loadTalkingKotlinFeed().map { it.toTalkingKotlinEntry() } + FeedSourceKey.TALKING_KOTLIN_PODCAST -> { + dataSource.loadTalkingKotlinFeed().map { it.toTalkingKotlinEntry() } } - FeedSourceKey.KOTLIN_WEEKLY -> with(kotlinWeeklyCacheContext) { - client.loadKotlinWeeklyFeed().map { it.toKotlinWeeklyEntry() } + FeedSourceKey.KOTLIN_WEEKLY -> { + dataSource.loadKotlinWeeklyFeed().map { it.toKotlinWeeklyEntry() } } } } diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt index db03017..edf62bc 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt @@ -5,14 +5,14 @@ import com.github.benmanes.caffeine.cache.Caffeine import com.netflix.graphql.dgs.DgsComponent import com.netflix.graphql.dgs.DgsQuery import com.netflix.graphql.dgs.InputArgument -import io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient +import io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource import io.github.reactivecircus.kstreamlined.backend.schema.generated.DgsConstants import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry import java.time.Duration @DgsComponent class KotlinWeeklyIssueDataFetcher( - private val client: KotlinWeeklyIssueClient + private val dataSource: KotlinWeeklyIssueDataSource ) { private val cache: Cache> = Caffeine .newBuilder() @@ -21,7 +21,7 @@ class KotlinWeeklyIssueDataFetcher( @DgsQuery(field = DgsConstants.QUERY.KotlinWeeklyIssue) suspend fun kotlinWeeklyIssue(@InputArgument url: String): List { - return cache.getIfPresent(url) ?: client.loadKotlinWeeklyIssue(url).also { + return cache.getIfPresent(url) ?: dataSource.loadKotlinWeeklyIssue(url).also { cache.put(url, it) } } diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapper.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapper.kt index 5542564..662ee9a 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapper.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapper.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinBlog import java.time.ZonedDateTime import java.time.format.DateTimeFormatter diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapper.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapper.kt index 14c1af3..dff12d5 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapper.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapper.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeekly import java.time.ZonedDateTime import java.time.format.DateTimeFormatter diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapper.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapper.kt index f0b991d..04d9e89 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapper.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapper.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinYouTube import java.time.ZonedDateTime import java.time.format.DateTimeFormatter diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapper.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapper.kt index cec8838..a5e1959 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapper.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapper.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.TalkingKotlin import java.time.Duration import java.time.ZonedDateTime diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/scalar/InstantScalar.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/scalar/InstantScalar.kt similarity index 94% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/scalar/InstantScalar.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/scalar/InstantScalar.kt index 4f84907..89b19a8 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/scalar/InstantScalar.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/scalar/InstantScalar.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.scalar +package io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar import com.netflix.graphql.dgs.DgsScalar import graphql.GraphQLContext diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoader.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoader.kt new file mode 100644 index 0000000..2b928d7 --- /dev/null +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoader.kt @@ -0,0 +1,85 @@ +package io.github.reactivecircus.kstreamlined.backend.datasource + +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient +import io.ktor.serialization.kotlinx.json.DefaultJson +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlin.time.Duration +import kotlin.time.toJavaDuration + +class DataLoader private constructor( + private val localCache: Cache>, + private val redisClient: RedisClient, + private val remoteCacheExpiry: Duration, + private val listSerializer: KSerializer>, +) { + class CacheConfig( + val localExpiry: Duration, + val remoteExpiry: Duration, + ) + + @Suppress("ReturnCount") + suspend fun load( + key: String, + sot: suspend () -> List + ): List { + // L1 cache - local + val l1Value = localCache.getIfPresent(key) + if (l1Value != null) { + return l1Value + } + + // L1 cache - remote (Redis) + val l2Value = redisClient.get(key) + if (l2Value != null) { + val value = DefaultJson.decodeFromString(listSerializer, l2Value) + localCache.put(key, value) + return value + } + + // Source of truth + val sotValue = sot() + localCache.put(key, sotValue) + redisClient.set( + key = key, + value = DefaultJson.encodeToString(listSerializer, sotValue), + keyExpirySeconds = remoteCacheExpiry.inWholeSeconds.toInt(), + ) + return sotValue + } + + companion object { + fun of( + cacheConfig: CacheConfig, + redisClient: RedisClient, + serializer: KSerializer, + ): DataLoader { + val localCache = Caffeine + .newBuilder() + .expireAfterWrite(cacheConfig.localExpiry.toJavaDuration()) + .build>() + return DataLoader( + localCache = localCache, + redisClient = redisClient, + remoteCacheExpiry = cacheConfig.remoteExpiry, + listSerializer = ListSerializer(serializer), + ) + } + + fun of( + localCache: Cache>, + remoteCacheExpiry: Duration, + redisClient: RedisClient, + serializer: KSerializer, + ): DataLoader { + return DataLoader( + localCache = localCache, + redisClient = redisClient, + remoteCacheExpiry = remoteCacheExpiry, + listSerializer = ListSerializer(serializer), + ) + } + } +} diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FeedDataSource.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FeedDataSource.kt new file mode 100644 index 0000000..62e606e --- /dev/null +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FeedDataSource.kt @@ -0,0 +1,116 @@ +package io.github.reactivecircus.kstreamlined.backend.datasource + +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogRss +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyRss +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeRss +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinRss +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.serialization.kotlinx.xml.DefaultXml +import io.ktor.serialization.kotlinx.xml.xml +import kotlinx.serialization.decodeFromString +import nl.adaptivity.xmlutil.ExperimentalXmlUtilApi +import nl.adaptivity.xmlutil.serialization.DefaultXmlSerializationPolicy +import nl.adaptivity.xmlutil.serialization.XmlConfig +import org.apache.commons.text.StringEscapeUtils + +interface FeedDataSource { + suspend fun loadKotlinBlogFeed(): List + suspend fun loadKotlinYouTubeFeed(): List + suspend fun loadTalkingKotlinFeed(): List + suspend fun loadKotlinWeeklyFeed(): List +} + +class FeedDataSourceConfig( + val kotlinBlogFeedUrl: String, + val kotlinYouTubeFeedUrl: String, + val talkingKotlinFeedUrl: String, + val kotlinWeeklyFeedUrl: String, +) + +class RealFeedDataSource( + engine: HttpClientEngine, + private val dataSourceConfig: FeedDataSourceConfig, + cacheConfig: DataLoader.CacheConfig, + redisClient: RedisClient, +) : FeedDataSource { + private val kotlinBlogFeedDataLoader = DataLoader.of(cacheConfig, redisClient, KotlinBlogItem.serializer()) + private val kotlinYouTubeFeedDataLoader = DataLoader.of(cacheConfig, redisClient, KotlinYouTubeItem.serializer()) + private val talkingKotlinFeedDataLoader = DataLoader.of(cacheConfig, redisClient, TalkingKotlinItem.serializer()) + private val kotlinWeeklyFeedDataLoader = DataLoader.of(cacheConfig, redisClient, KotlinWeeklyItem.serializer()) + + @OptIn(ExperimentalXmlUtilApi::class) + private val httpClient = HttpClient(engine) { + expectSuccess = true + install(ContentNegotiation) { + val format = DefaultXml.copy { + policy = DefaultXmlSerializationPolicy( + DefaultXmlSerializationPolicy.Builder().apply { + pedantic = false + unknownChildHandler = XmlConfig.IGNORING_UNKNOWN_CHILD_HANDLER + }.build() + ) + } + + xml(format, ContentType.Application.Rss) + xml(format, ContentType.Application.Xml) + xml(format, ContentType.Text.Xml) + } + install(HttpTimeout) { + connectTimeoutMillis = HttpTimeoutMillis + requestTimeoutMillis = HttpTimeoutMillis + } + } + + override suspend fun loadKotlinBlogFeed(): List { + return kotlinBlogFeedDataLoader.load("kotlin-blog") { + httpClient.get(dataSourceConfig.kotlinBlogFeedUrl).body().channel.items.map { + it.copy( + description = StringEscapeUtils.unescapeXml(it.description).trim() + ) + } + } + } + + override suspend fun loadKotlinYouTubeFeed(): List { + return kotlinYouTubeFeedDataLoader.load("kotlin-youtube") { + httpClient.get(dataSourceConfig.kotlinYouTubeFeedUrl).bodyAsText().let { + DefaultXml.decodeFromString(it.replace("&(?!.{2,4};)".toRegex(), "&")).entries + } + } + } + + override suspend fun loadTalkingKotlinFeed(): List { + return talkingKotlinFeedDataLoader.load("talking-kotlin") { + httpClient.get(dataSourceConfig.talkingKotlinFeedUrl).body().channel.items + .take(TalkingKotlinFeedSize) + .map { + it.copy( + summary = StringEscapeUtils.unescapeXml(it.summary).trim() + ) + } + } + } + + override suspend fun loadKotlinWeeklyFeed(): List { + return kotlinWeeklyFeedDataLoader.load("kotlin-weekly") { + httpClient.get(dataSourceConfig.kotlinWeeklyFeedUrl).body().channel.items + } + } + + companion object { + private const val HttpTimeoutMillis = 30_000L + private const val TalkingKotlinFeedSize = 10 + } +} diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/KotlinWeeklyIssueDataSource.kt similarity index 96% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/KotlinWeeklyIssueDataSource.kt index f2ccfb7..917de69 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/KotlinWeeklyIssueDataSource.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryGroup @@ -13,13 +13,13 @@ import it.skrape.selects.html5.a import it.skrape.selects.html5.div import it.skrape.selects.html5.span -interface KotlinWeeklyIssueClient { +interface KotlinWeeklyIssueDataSource { suspend fun loadKotlinWeeklyIssue(url: String): List } -class RealKotlinWeeklyIssueClient( +class RealKotlinWeeklyIssueDataSource( engine: HttpClientEngine, -) : KotlinWeeklyIssueClient { +) : KotlinWeeklyIssueDataSource { private val httpClient = HttpClient(engine) { expectSuccess = true diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/CommonDTOs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/CommonDTOs.kt similarity index 90% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/CommonDTOs.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/CommonDTOs.kt index 441bc6d..df4c637 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/CommonDTOs.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/CommonDTOs.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client.dto +package io.github.reactivecircus.kstreamlined.backend.datasource.dto import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinBlogDTOs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinBlogDTOs.kt similarity index 91% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinBlogDTOs.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinBlogDTOs.kt index 4f2cb3e..f50fc35 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinBlogDTOs.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinBlogDTOs.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client.dto +package io.github.reactivecircus.kstreamlined.backend.datasource.dto import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinWeeklyDTOs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinWeeklyDTOs.kt similarity index 90% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinWeeklyDTOs.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinWeeklyDTOs.kt index f43f78e..f361c88 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinWeeklyDTOs.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinWeeklyDTOs.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client.dto +package io.github.reactivecircus.kstreamlined.backend.datasource.dto import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinYouTubeDTOs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinYouTubeDTOs.kt similarity index 98% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinYouTubeDTOs.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinYouTubeDTOs.kt index ce9b308..f937928 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/KotlinYouTubeDTOs.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/KotlinYouTubeDTOs.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client.dto +package io.github.reactivecircus.kstreamlined.backend.datasource.dto import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/TalkingKotlinDTOs.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/TalkingKotlinDTOs.kt similarity index 94% rename from src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/TalkingKotlinDTOs.kt rename to src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/TalkingKotlinDTOs.kt index dd5f160..7b06548 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/dto/TalkingKotlinDTOs.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/dto/TalkingKotlinDTOs.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client.dto +package io.github.reactivecircus.kstreamlined.backend.datasource.dto import kotlinx.serialization.Serializable import nl.adaptivity.xmlutil.serialization.XmlElement diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClient.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClient.kt new file mode 100644 index 0000000..62f0da9 --- /dev/null +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClient.kt @@ -0,0 +1,88 @@ +package io.github.reactivecircus.kstreamlined.backend.redis + +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.ClientRequestException +import io.ktor.client.plugins.HttpRequestTimeoutException +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.plugins.ResponseException +import io.ktor.client.plugins.ServerResponseException +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.request.bearerAuth +import io.ktor.client.request.get +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType +import io.ktor.serialization.kotlinx.json.DefaultJson +import io.ktor.serialization.kotlinx.json.json +import org.slf4j.LoggerFactory + +class RedisClient( + engine: HttpClientEngine, + private val url: String, + private val token: String, +) { + private val logger = LoggerFactory.getLogger(this::class.java) + + private val httpClient = HttpClient(engine) { + expectSuccess = true + install(ContentNegotiation) { + json(DefaultJson) + } + install(HttpTimeout) { + connectTimeoutMillis = HttpTimeoutMillis + requestTimeoutMillis = HttpTimeoutMillis + } + } + + suspend fun get(key: String): String? { + return executeWithErrorHandling(operation = "GET", key = key) { + httpClient.get("$url/get/$key") { + bearerAuth(token) + }.bodyAsText().let { result -> + DefaultJson.decodeFromString>(result)["result"] + } + } + } + + suspend fun set(key: String, value: String, keyExpirySeconds: Int = DefaultKeyExpirySeconds) { + executeWithErrorHandling(operation = "SET", key = key) { + httpClient.post("$url/set/$key") { + parameter("EX", keyExpirySeconds) + contentType(ContentType.Application.Json) + setBody(value) + bearerAuth(token) + } + } + } + + private suspend fun executeWithErrorHandling( + operation: String, + key: String, + block: suspend () -> T, + ): T? { + return try { + block() + } catch (e: ClientRequestException) { + logger.error("Client error during Redis $operation operation for key: $key", e) + null + } catch (e: ServerResponseException) { + logger.error("Server error during Redis $operation operation for key: $key", e) + null + } catch (e: HttpRequestTimeoutException) { + logger.error("Timeout during Redis $operation operation for key: $key", e) + null + } catch (e: ResponseException) { + logger.error("Unexpected response during Redis $operation operation for key: $key", e) + null + } + } + + companion object { + private const val HttpTimeoutMillis = 5_000L + private const val DefaultKeyExpirySeconds = 3600 + } +} diff --git a/src/main/resources/META-INF/native-image/reflect-config.json b/src/main/resources/META-INF/native-image/reflect-config.json index ed751db..b07ea77 100644 --- a/src/main/resources/META-INF/native-image/reflect-config.json +++ b/src/main/resources/META-INF/native-image/reflect-config.json @@ -699,6 +699,30 @@ { "name":"com.sendgrid.SendGrid" }, +{ + "name":"com.sun.crypto.provider.AESCipher$General", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.ARCFOURCipher", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.ChaCha20Cipher$ChaCha20Poly1305", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DESCipher", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DESedeCipher", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"com.sun.crypto.provider.DHParameters", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"com.sun.crypto.provider.GaloisCounterMode$AESGCM", "methods":[{"name":"","parameterTypes":[] }] @@ -711,6 +735,10 @@ "name":"com.sun.crypto.provider.HmacCore$HmacSHA384", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"com.sun.crypto.provider.TlsMasterSecretGenerator", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"com.sun.xml.internal.stream.XMLInputFactoryImpl", "methods":[{"name":"","parameterTypes":[] }] @@ -830,7 +858,7 @@ "name":"io.github.reactivecircus.kstreamlined.backend.KSConfiguration", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"clientConfigs","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String"] }, {"name":"feedClient","parameterTypes":["io.ktor.client.engine.HttpClientEngine","io.github.reactivecircus.kstreamlined.backend.client.ClientConfigs"] }, {"name":"httpClientEngine","parameterTypes":[] }, {"name":"kotlinWeeklyIssueClient","parameterTypes":["io.ktor.client.engine.HttpClientEngine"] }, {"name":"setBeanFactory","parameterTypes":["org.springframework.beans.factory.BeanFactory"] }] + "methods":[{"name":"","parameterTypes":[] }, {"name":"feedDataSource","parameterTypes":["io.ktor.client.engine.HttpClientEngine","io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSourceConfig","io.github.reactivecircus.kstreamlined.backend.redis.RedisClient"] }, {"name":"feedDataSourceConfig","parameterTypes":["java.lang.String","java.lang.String","java.lang.String","java.lang.String"] }, {"name":"httpClientEngine","parameterTypes":[] }, {"name":"kotlinWeeklyIssueDataSource","parameterTypes":["io.ktor.client.engine.HttpClientEngine"] }, {"name":"redisClient","parameterTypes":["io.ktor.client.engine.HttpClientEngine","java.lang.String","java.lang.String"] }, {"name":"setBeanFactory","parameterTypes":["org.springframework.beans.factory.BeanFactory"] }] }, { "name":"io.github.reactivecircus.kstreamlined.backend.KSConfiguration$$SpringCGLIB$$0", @@ -849,96 +877,90 @@ "methods":[{"name":"","parameterTypes":["java.lang.Class"] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.ClientConfigs", + "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "methods":[{"name":"close","parameterTypes":[] }, {"name":"shutdown","parameterTypes":[] }] -}, -{ - "name":"io.github.reactivecircus.kstreamlined.backend.client.FeedClient", - "queryAllDeclaredMethods":true, - "queryAllPublicMethods":true + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource"] }, {"name":"feedEntries","parameterTypes":["java.util.List","kotlin.coroutines.Continuation"] }, {"name":"resolveFeedEntry$kstreamlined_backend","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedEntry"] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient", + "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedSourceDataFetcher", + "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "queryAllPublicMethods":true + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"feedSources","parameterTypes":["kotlin.coroutines.Continuation"] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.RealFeedClient", + "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.KotlinWeeklyIssueDataFetcher", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "methods":[{"name":"close","parameterTypes":[] }, {"name":"loadKotlinBlogFeed","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.CacheContext","kotlin.coroutines.Continuation"] }, {"name":"loadKotlinWeeklyFeed","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.CacheContext","kotlin.coroutines.Continuation"] }, {"name":"loadKotlinYouTubeFeed","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.CacheContext","kotlin.coroutines.Continuation"] }, {"name":"loadTalkingKotlinFeed","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.CacheContext","kotlin.coroutines.Continuation"] }, {"name":"shutdown","parameterTypes":[] }] + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource"] }, {"name":"kotlinWeeklyIssue","parameterTypes":["java.lang.String","kotlin.coroutines.Continuation"] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.RealKotlinWeeklyIssueClient", + "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar.InstantScalar", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "methods":[{"name":"close","parameterTypes":[] }, {"name":"loadKotlinWeeklyIssue","parameterTypes":["java.lang.String","kotlin.coroutines.Continuation"] }, {"name":"shutdown","parameterTypes":[] }] -}, -{ - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogRss", - "fields":[{"name":"Companion"}] -}, -{ - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogRss$Companion", - "methods":[{"name":"serializer","parameterTypes":[] }] + "queryAllDeclaredConstructors":true, + "methods":[{"name":"","parameterTypes":[] }, {"name":"parseLiteral","parameterTypes":["graphql.language.Value","graphql.execution.CoercedVariables","graphql.GraphQLContext","java.util.Locale"] }, {"name":"parseValue","parameterTypes":["java.lang.Object","graphql.GraphQLContext","java.util.Locale"] }, {"name":"serialize","parameterTypes":["java.lang.Object","graphql.GraphQLContext","java.util.Locale"] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyRss", - "fields":[{"name":"Companion"}] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource", + "queryAllDeclaredMethods":true, + "queryAllPublicMethods":true }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyRss$Companion", - "methods":[{"name":"serializer","parameterTypes":[] }] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSourceConfig", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"close","parameterTypes":[] }, {"name":"shutdown","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinRss", - "fields":[{"name":"Companion"}] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource", + "queryAllDeclaredMethods":true, + "queryAllPublicMethods":true }, { - "name":"io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinRss$Companion", - "methods":[{"name":"serializer","parameterTypes":[] }] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.RealFeedDataSource", + "allDeclaredFields":true, + "queryAllDeclaredMethods":true, + "methods":[{"name":"close","parameterTypes":[] }, {"name":"loadKotlinBlogFeed","parameterTypes":["kotlin.coroutines.Continuation"] }, {"name":"loadKotlinWeeklyFeed","parameterTypes":["kotlin.coroutines.Continuation"] }, {"name":"loadKotlinYouTubeFeed","parameterTypes":["kotlin.coroutines.Continuation"] }, {"name":"loadTalkingKotlinFeed","parameterTypes":["kotlin.coroutines.Continuation"] }, {"name":"shutdown","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher", + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.RealKotlinWeeklyIssueDataSource", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "queryAllDeclaredConstructors":true, - "methods":[{"name":"","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.FeedClient"] }, {"name":"feedEntries","parameterTypes":["java.util.List","kotlin.coroutines.Continuation"] }, {"name":"resolveFeedEntry$kstreamlined_backend","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedEntry"] }] + "methods":[{"name":"close","parameterTypes":[] }, {"name":"loadKotlinWeeklyIssue","parameterTypes":["java.lang.String","kotlin.coroutines.Continuation"] }, {"name":"shutdown","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher$kotlinBlogCacheContext$1" + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogRss", + "fields":[{"name":"Companion"}] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher$kotlinWeeklyCacheContext$1" + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogRss$Companion", + "methods":[{"name":"serializer","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher$kotlinYouTubeCacheContext$1" + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyRss", + "fields":[{"name":"Companion"}] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedEntryDataFetcher$talkingKotlinCacheContext$1" + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyRss$Companion", + "methods":[{"name":"serializer","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.FeedSourceDataFetcher", - "allDeclaredFields":true, - "queryAllDeclaredMethods":true, - "queryAllDeclaredConstructors":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"feedSources","parameterTypes":["kotlin.coroutines.Continuation"] }] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinRss", + "fields":[{"name":"Companion"}] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.datafetcher.KotlinWeeklyIssueDataFetcher", - "allDeclaredFields":true, - "queryAllDeclaredMethods":true, - "queryAllDeclaredConstructors":true, - "methods":[{"name":"","parameterTypes":["io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient"] }, {"name":"kotlinWeeklyIssue","parameterTypes":["java.lang.String","kotlin.coroutines.Continuation"] }] + "name":"io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinRss$Companion", + "methods":[{"name":"serializer","parameterTypes":[] }] }, { - "name":"io.github.reactivecircus.kstreamlined.backend.scalar.InstantScalar", + "name":"io.github.reactivecircus.kstreamlined.backend.redis.RedisClient", "allDeclaredFields":true, "queryAllDeclaredMethods":true, - "queryAllDeclaredConstructors":true, - "methods":[{"name":"","parameterTypes":[] }, {"name":"parseLiteral","parameterTypes":["graphql.language.Value","graphql.execution.CoercedVariables","graphql.GraphQLContext","java.util.Locale"] }, {"name":"parseValue","parameterTypes":["java.lang.Object","graphql.GraphQLContext","java.util.Locale"] }, {"name":"serialize","parameterTypes":["java.lang.Object","graphql.GraphQLContext","java.util.Locale"] }] + "methods":[{"name":"close","parameterTypes":[] }, {"name":"shutdown","parameterTypes":[] }] }, { "name":"io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedEntry" @@ -996,34 +1018,26 @@ "methods":[{"name":"getDispatcher","parameterTypes":[] }, {"name":"install","parameterTypes":["io.ktor.client.HttpClient"] }] }, { - "name":"io.ktor.client.engine.cio.CIOEngine", + "name":"io.ktor.client.engine.okhttp.OkHttpEngine", "allDeclaredFields":true, "queryAllDeclaredMethods":true, "methods":[{"name":"close","parameterTypes":[] }, {"name":"execute","parameterTypes":["io.ktor.client.request.HttpRequestData","kotlin.coroutines.Continuation"] }, {"name":"getConfig","parameterTypes":[] }, {"name":"getCoroutineContext","parameterTypes":[] }, {"name":"getSupportedCapabilities","parameterTypes":[] }] }, { - "name":"io.ktor.client.engine.cio.Endpoint", - "fields":[{"name":"connections"}] -}, -{ - "name":"io.ktor.client.plugins.internal.ByteChannelReplay", - "fields":[{"name":"content"}] + "name":"io.ktor.client.plugins.DefaultResponseValidationKt$addDefaultResponseValidation$1$1" }, { - "name":"io.ktor.network.selector.InterestSuspensionsMap", - "fields":[{"name":"acceptHandlerReference"}, {"name":"connectHandlerReference"}, {"name":"readHandlerReference"}, {"name":"writeHandlerReference"}] + "name":"io.ktor.client.plugins.HttpCallValidatorKt" }, { - "name":"io.ktor.network.selector.LockFreeMPSCQueue", - "fields":[{"name":"_cur"}] + "name":"io.ktor.client.plugins.HttpCallValidatorKt$HttpCallValidator$2$2" }, { - "name":"io.ktor.network.selector.LockFreeMPSCQueueCore", - "fields":[{"name":"_next"}, {"name":"_state"}] + "name":"io.ktor.client.plugins.internal.ByteChannelReplay", + "fields":[{"name":"content"}] }, { - "name":"io.ktor.network.selector.SelectableBase", - "fields":[{"name":"_interestedOps"}] + "name":"io.ktor.serialization.kotlinx.json.KotlinxSerializationJsonExtensionProvider" }, { "name":"io.ktor.util.collections.CopyOnWriteHashMap", @@ -1037,10 +1051,6 @@ "name":"io.ktor.utils.io.InternalAPI", "queryAllDeclaredMethods":true }, -{ - "name":"io.ktor.utils.io.pool.DefaultPool", - "fields":[{"name":"top"}] -}, { "name":"io.micrometer.context.ContextRegistry", "allDeclaredFields":true, @@ -1185,6 +1195,10 @@ "name":"io.netty.util.ReferenceCountUtil", "queryAllDeclaredMethods":true }, +{ + "name":"io.netty.util.ResourceLeakDetector$DefaultResourceLeak", + "fields":[{"name":"droppedRecords"}, {"name":"head"}] +}, { "name":"io.netty.util.concurrent.DefaultPromise", "fields":[{"name":"result"}] @@ -1443,9 +1457,6 @@ { "name":"java.net.SocketPermission" }, -{ - "name":"java.net.StandardSocketOptions" -}, { "name":"java.net.URLPermission", "methods":[{"name":"","parameterTypes":["java.lang.String","java.lang.String"] }] @@ -1509,6 +1520,11 @@ { "name":"java.util.Date" }, +{ + "name":"java.util.LinkedHashMap", + "allDeclaredClasses":true, + "fields":[{"name":"Companion"}] +}, { "name":"java.util.List" }, @@ -1671,6 +1687,9 @@ { "name":"kotlin.coroutines.Continuation" }, +{ + "name":"kotlin.coroutines.jvm.internal.BaseContinuationImpl" +}, { "name":"kotlin.coroutines.jvm.internal.DebugMetadata", "queryAllDeclaredMethods":true @@ -1733,6 +1752,9 @@ "queryAllDeclaredMethods":true, "queryAllPublicMethods":true }, +{ + "name":"kotlinx.coroutines.DispatchedTask" +}, { "name":"kotlinx.coroutines.EventLoopImplBase", "fields":[{"name":"_delayed$volatile"}, {"name":"_isCompleted$volatile"}, {"name":"_queue$volatile"}] @@ -1749,10 +1771,6 @@ "name":"kotlinx.coroutines.JobSupport$Finishing", "fields":[{"name":"_exceptionsHolder$volatile"}, {"name":"_isCompleting$volatile"}, {"name":"_rootCause$volatile"}] }, -{ - "name":"kotlinx.coroutines.channels.BufferedChannel", - "fields":[{"name":"_closeCause$volatile"}, {"name":"bufferEnd$volatile"}, {"name":"bufferEndSegment$volatile"}, {"name":"closeHandler$volatile"}, {"name":"completedExpandBuffersAndPauseFlag$volatile"}, {"name":"receiveSegment$volatile"}, {"name":"receivers$volatile"}, {"name":"sendSegment$volatile"}, {"name":"sendersAndCloseStatus$volatile"}] -}, { "name":"kotlinx.coroutines.flow.Flow" }, @@ -1760,10 +1778,6 @@ "name":"kotlinx.coroutines.internal.AtomicOp", "fields":[{"name":"_consensus$volatile"}] }, -{ - "name":"kotlinx.coroutines.internal.ConcurrentLinkedListNode", - "fields":[{"name":"_next$volatile"}, {"name":"_prev$volatile"}] -}, { "name":"kotlinx.coroutines.internal.DispatchedContinuation", "fields":[{"name":"_reusableCancellableContinuation$volatile"}] @@ -1772,6 +1786,9 @@ "name":"kotlinx.coroutines.internal.LimitedDispatcher", "fields":[{"name":"runningWorkers$volatile"}] }, +{ + "name":"kotlinx.coroutines.internal.LimitedDispatcher$Worker" +}, { "name":"kotlinx.coroutines.internal.LockFreeLinkedListNode", "fields":[{"name":"_next$volatile"}, {"name":"_prev$volatile"}, {"name":"_removedRef$volatile"}] @@ -1784,10 +1801,6 @@ "name":"kotlinx.coroutines.internal.LockFreeTaskQueueCore", "fields":[{"name":"_next$volatile"}, {"name":"_state$volatile"}] }, -{ - "name":"kotlinx.coroutines.internal.Segment", - "fields":[{"name":"cleanedAndPointers$volatile"}] -}, { "name":"kotlinx.coroutines.internal.ThreadSafeHeap", "fields":[{"name":"_size$volatile"}] @@ -1804,12 +1817,11 @@ "fields":[{"name":"workerCtl$volatile"}] }, { - "name":"kotlinx.coroutines.scheduling.WorkQueue", - "fields":[{"name":"blockingTasksInBuffer$volatile"}, {"name":"consumerIndex$volatile"}, {"name":"lastScheduledTask$volatile"}, {"name":"producerIndex$volatile"}] + "name":"kotlinx.coroutines.scheduling.TaskImpl" }, { - "name":"kotlinx.coroutines.sync.SemaphoreImpl", - "fields":[{"name":"_availablePermits$volatile"}, {"name":"deqIdx$volatile"}, {"name":"enqIdx$volatile"}, {"name":"head$volatile"}, {"name":"tail$volatile"}] + "name":"kotlinx.coroutines.scheduling.WorkQueue", + "fields":[{"name":"blockingTasksInBuffer$volatile"}, {"name":"consumerIndex$volatile"}, {"name":"lastScheduledTask$volatile"}, {"name":"producerIndex$volatile"}] }, { "name":"kotlinx.io.RefCountingCopyTracker", @@ -3748,6 +3760,7 @@ { "name":"org.springframework.graphql.server.support.SerializableGraphQlRequest", "allDeclaredFields":true, + "allDeclaredClasses":true, "queryAllDeclaredMethods":true, "queryAllDeclaredConstructors":true, "methods":[{"name":"","parameterTypes":[] }, {"name":"setOperationName","parameterTypes":["java.lang.String"] }, {"name":"setQuery","parameterTypes":["java.lang.String"] }, {"name":"setVariables","parameterTypes":["java.util.Map"] }] @@ -4279,30 +4292,10 @@ "name":"reactor.core.publisher.FluxFirstWithSignal$RaceCoordinator", "fields":[{"name":"winner"}] }, -{ - "name":"reactor.core.publisher.FluxGenerate$GenerateSubscription", - "fields":[{"name":"requested"}] -}, { "name":"reactor.core.publisher.FluxIterable$IterableSubscription", "fields":[{"name":"requested"}] }, -{ - "name":"reactor.core.publisher.FluxLimitRequest$FluxLimitRequestSubscriber", - "fields":[{"name":"requestRemaining"}] -}, -{ - "name":"reactor.core.publisher.FluxOnErrorReturn$ReturnSubscriber", - "fields":[{"name":"requested"}] -}, -{ - "name":"reactor.core.publisher.FluxSubscribeOn$SubscribeOnSubscriber", - "fields":[{"name":"requested"}, {"name":"s"}, {"name":"thread"}] -}, -{ - "name":"reactor.core.publisher.FluxUsing$UsingFuseableSubscriber", - "fields":[{"name":"wip"}] -}, { "name":"reactor.core.publisher.Hooks" }, @@ -4341,10 +4334,6 @@ "name":"reactor.core.publisher.MonoNext$NextSubscriber", "fields":[{"name":"wip"}] }, -{ - "name":"reactor.core.publisher.MonoSubscribeOn$SubscribeOnSubscriber", - "fields":[{"name":"requested"}, {"name":"s"}, {"name":"thread"}] -}, { "name":"reactor.core.publisher.MonoWhen$WhenCoordinator", "fields":[{"name":"state"}] @@ -4389,10 +4378,6 @@ "name":"reactor.core.scheduler.BoundedElasticScheduler$BoundedState", "fields":[{"name":"markCount"}] }, -{ - "name":"reactor.core.scheduler.WorkerTask", - "fields":[{"name":"future"}, {"name":"parent"}, {"name":"thread"}] -}, { "name":"reactor.netty.channel.ChannelOperations", "fields":[{"name":"outboundSubscription"}] @@ -4405,10 +4390,6 @@ "name":"reactor.netty.channel.FluxReceive", "fields":[{"name":"receiverCancel"}] }, -{ - "name":"reactor.netty.channel.MonoSendMany$SendManyInner", - "fields":[{"name":"s"}, {"name":"wip"}] -}, { "name":"reactor.netty.contextpropagation.ChannelContextAccessor" }, @@ -4457,14 +4438,6 @@ { "name":"reactor.tools.agent.ReactorDebugAgent" }, -{ - "name":"reactor.util.concurrent.SpscArrayQueueConsumer", - "fields":[{"name":"consumerIndex"}] -}, -{ - "name":"reactor.util.concurrent.SpscArrayQueueProducer", - "fields":[{"name":"producerIndex"}] -}, { "name":"reactor.util.context.ReactorContextAccessor" }, @@ -4485,6 +4458,14 @@ "name":"sun.security.pkcs12.PKCS12KeyStore$DualFormatPKCS12", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.provider.DSA$SHA224withDSA", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.provider.DSA$SHA256withDSA", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.provider.NativePRNG", "methods":[{"name":"","parameterTypes":["java.security.SecureRandomParameters"] }] @@ -4493,6 +4474,10 @@ "name":"sun.security.provider.SHA", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.provider.SHA2$SHA224", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.provider.SHA2$SHA256", "methods":[{"name":"","parameterTypes":[] }] @@ -4501,6 +4486,10 @@ "name":"sun.security.provider.SHA5$SHA384", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.provider.SHA5$SHA512", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.provider.X509Factory", "methods":[{"name":"","parameterTypes":[] }] @@ -4509,14 +4498,30 @@ "name":"sun.security.provider.certpath.PKIXCertPathValidator", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.rsa.PSSParameters", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.rsa.RSAKeyFactory$Legacy", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.rsa.RSAPSSSignature", + "methods":[{"name":"","parameterTypes":[] }] +}, +{ + "name":"sun.security.rsa.RSASignature$SHA224withRSA", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.rsa.RSASignature$SHA256withRSA", "methods":[{"name":"","parameterTypes":[] }] }, +{ + "name":"sun.security.ssl.SSLContextImpl$TLSContext", + "methods":[{"name":"","parameterTypes":[] }] +}, { "name":"sun.security.ssl.TrustManagerFactoryImpl$PKIXFactory", "methods":[{"name":"","parameterTypes":[] }] @@ -4563,6 +4568,10 @@ "name":"sun.security.x509.NetscapeCertTypeExtension", "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] }, +{ + "name":"sun.security.x509.OCSPNoCheckExtension", + "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] +}, { "name":"sun.security.x509.PrivateKeyUsageExtension", "methods":[{"name":"","parameterTypes":["java.lang.Boolean","java.lang.Object"] }] diff --git a/src/main/resources/META-INF/native-image/resource-config.json b/src/main/resources/META-INF/native-image/resource-config.json index 63d1555..74acb41 100644 --- a/src/main/resources/META-INF/native-image/resource-config.json +++ b/src/main/resources/META-INF/native-image/resource-config.json @@ -151,7 +151,7 @@ }, { "pattern":"\\Qio/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.class\\E" }, { - "pattern":"\\Qio/github/reactivecircus/kstreamlined/backend/scalar/InstantScalar.class\\E" + "pattern":"\\Qio/github/reactivecircus/kstreamlined/backend/datafetcher/scalar/InstantScalar.class\\E" }, { "pattern":"\\Qkotlin/Metadata.class\\E" }, { @@ -464,6 +464,10 @@ "pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt74b/nfc.nrm\\E" }, { "pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt74b/nfkc.nrm\\E" + }, { + "pattern":"java.base:\\Qjdk/internal/icu/impl/data/icudt74b/uprops.icu\\E" + }, { + "pattern":"java.base:\\Qsun/net/idn/uidna.spp\\E" }, { "pattern":"java.xml:\\Qjdk/xml/internal/jdkcatalog/JDKCatalog.xml\\E" }, { diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt index 678ec1f..ffe8819 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt @@ -1,9 +1,11 @@ package io.github.reactivecircus.kstreamlined.backend -import io.github.reactivecircus.kstreamlined.backend.client.FakeFeedClient -import io.github.reactivecircus.kstreamlined.backend.client.FakeKotlinWeeklyIssueClient -import io.github.reactivecircus.kstreamlined.backend.client.FeedClient -import io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient +import io.github.reactivecircus.kstreamlined.backend.datasource.FakeFeedDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.FakeKotlinWeeklyIssueDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.NoOpRedisClient +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -11,12 +13,17 @@ import org.springframework.context.annotation.Configuration class TestKSConfiguration { @Bean - fun feedClient(): FeedClient { - return FakeFeedClient + fun feedDataSource(): FeedDataSource { + return FakeFeedDataSource } @Bean - fun kotlinWeeklyIssueClient(): KotlinWeeklyIssueClient { - return FakeKotlinWeeklyIssueClient + fun kotlinWeeklyIssueDataSource(): KotlinWeeklyIssueDataSource { + return FakeKotlinWeeklyIssueDataSource + } + + @Bean + fun redisClient(): RedisClient { + return NoOpRedisClient } } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcherTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcherTest.kt index d97532c..2d23603 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcherTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedEntryDataFetcherTest.kt @@ -4,17 +4,17 @@ import com.netflix.graphql.dgs.DgsQueryExecutor import com.netflix.graphql.dgs.autoconfig.DgsAutoConfiguration import graphql.GraphqlErrorException import io.github.reactivecircus.kstreamlined.backend.TestKSConfiguration -import io.github.reactivecircus.kstreamlined.backend.client.DummyKotlinBlogItem -import io.github.reactivecircus.kstreamlined.backend.client.DummyKotlinWeeklyItem -import io.github.reactivecircus.kstreamlined.backend.client.DummyKotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.DummyTalkingKotlinItem -import io.github.reactivecircus.kstreamlined.backend.client.FakeFeedClient -import io.github.reactivecircus.kstreamlined.backend.client.FeedClient import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinBlogEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinWeeklyEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toKotlinYouTubeEntry import io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper.toTalkingKotlinEntry -import io.github.reactivecircus.kstreamlined.backend.scalar.InstantScalar +import io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar.InstantScalar +import io.github.reactivecircus.kstreamlined.backend.datasource.DummyKotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.DummyKotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.DummyKotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.DummyTalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.FakeFeedDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.FeedDataSource import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedSourceKey import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest @@ -30,7 +30,7 @@ class FeedEntryDataFetcherTest { private lateinit var dgsQueryExecutor: DgsQueryExecutor @Autowired - private lateinit var feedClient: FeedClient + private lateinit var feedDataSource: FeedDataSource private val feedEntriesQuery = """ query FeedEntriesQuery(${"$"}filters: [FeedSourceKey!]) { @@ -62,17 +62,17 @@ class FeedEntryDataFetcherTest { """.trimIndent() @Test - fun `feedEntries() query returns expected feed entries ordered by publish time when operation was successful`() { - (feedClient as FakeFeedClient).nextKotlinBlogFeedResponse = { + fun `feedEntries() query returns expected feed entries ordered by publish time when operation succeeds`() { + (feedDataSource as FakeFeedDataSource).nextKotlinBlogFeedResponse = { listOf(DummyKotlinBlogItem) } - (feedClient as FakeFeedClient).nextKotlinYouTubeFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinYouTubeFeedResponse = { listOf(DummyKotlinYouTubeItem) } - (feedClient as FakeFeedClient).nextTalkingKotlinFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextTalkingKotlinFeedResponse = { listOf(DummyTalkingKotlinItem) } - (feedClient as FakeFeedClient).nextKotlinWeeklyFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinWeeklyFeedResponse = { listOf(DummyKotlinWeeklyItem) } @@ -116,16 +116,16 @@ class FeedEntryDataFetcherTest { @Test fun `feedEntries() query returns error response when failed to load data from any feed sources`() { - (feedClient as FakeFeedClient).nextKotlinBlogFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinBlogFeedResponse = { throw GraphqlErrorException.newErrorException().build() } - (feedClient as FakeFeedClient).nextKotlinYouTubeFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinYouTubeFeedResponse = { listOf(DummyKotlinYouTubeItem) } - (feedClient as FakeFeedClient).nextTalkingKotlinFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextTalkingKotlinFeedResponse = { listOf(DummyTalkingKotlinItem) } - (feedClient as FakeFeedClient).nextKotlinWeeklyFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinWeeklyFeedResponse = { listOf(DummyKotlinWeeklyItem) } @@ -136,16 +136,16 @@ class FeedEntryDataFetcherTest { @Test fun `feedEntries(filters) query returns expected feed entries from selected sources when filters are provided`() { - (feedClient as FakeFeedClient).nextKotlinBlogFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinBlogFeedResponse = { listOf(DummyKotlinBlogItem) } - (feedClient as FakeFeedClient).nextKotlinYouTubeFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinYouTubeFeedResponse = { listOf(DummyKotlinYouTubeItem) } - (feedClient as FakeFeedClient).nextTalkingKotlinFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextTalkingKotlinFeedResponse = { listOf(DummyTalkingKotlinItem) } - (feedClient as FakeFeedClient).nextKotlinWeeklyFeedResponse = { + (feedDataSource as FakeFeedDataSource).nextKotlinWeeklyFeedResponse = { listOf(DummyKotlinWeeklyItem) } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedSourceDataFetcherTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedSourceDataFetcherTest.kt index 8f7e632..0a74d14 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedSourceDataFetcherTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/FeedSourceDataFetcherTest.kt @@ -3,7 +3,7 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher import com.netflix.graphql.dgs.DgsQueryExecutor import com.netflix.graphql.dgs.autoconfig.DgsAutoConfiguration import io.github.reactivecircus.kstreamlined.backend.TestKSConfiguration -import io.github.reactivecircus.kstreamlined.backend.scalar.InstantScalar +import io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar.InstantScalar import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.FeedSourceKey import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt index 1c619de..45534f2 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt @@ -3,10 +3,10 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher import com.netflix.graphql.dgs.DgsQueryExecutor import com.netflix.graphql.dgs.autoconfig.DgsAutoConfiguration import io.github.reactivecircus.kstreamlined.backend.TestKSConfiguration -import io.github.reactivecircus.kstreamlined.backend.client.DummyKotlinWeeklyIssueEntries -import io.github.reactivecircus.kstreamlined.backend.client.FakeKotlinWeeklyIssueClient -import io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient -import io.github.reactivecircus.kstreamlined.backend.scalar.InstantScalar +import io.github.reactivecircus.kstreamlined.backend.datafetcher.scalar.InstantScalar +import io.github.reactivecircus.kstreamlined.backend.datasource.DummyKotlinWeeklyIssueEntries +import io.github.reactivecircus.kstreamlined.backend.datasource.FakeKotlinWeeklyIssueDataSource +import io.github.reactivecircus.kstreamlined.backend.datasource.KotlinWeeklyIssueDataSource import org.springframework.beans.factory.annotation.Autowired import org.springframework.boot.test.context.SpringBootTest import org.springframework.test.context.ContextConfiguration @@ -20,7 +20,7 @@ class KotlinWeeklyIssueDataFetcherTest { private lateinit var dgsQueryExecutor: DgsQueryExecutor @Autowired - private lateinit var kotlinWeeklyIssueClient: KotlinWeeklyIssueClient + private lateinit var kotlinWeeklyIssueDataSource: KotlinWeeklyIssueDataSource private val kotlinWeeklyIssueQuery = """ query KotlinWeeklyIssue(${"$"}url: String!) { @@ -35,8 +35,8 @@ class KotlinWeeklyIssueDataFetcherTest { """.trimIndent() @Test - fun `kotlinWeeklyIssue(url) query returns expected kotlin weekly issue entries when operation was successful`() { - (kotlinWeeklyIssueClient as FakeKotlinWeeklyIssueClient).nextKotlinWeeklyIssueResponse = { + fun `kotlinWeeklyIssue(url) query returns expected kotlin weekly issue entries when operation succeeds`() { + (kotlinWeeklyIssueDataSource as FakeKotlinWeeklyIssueDataSource).nextKotlinWeeklyIssueResponse = { DummyKotlinWeeklyIssueEntries } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapperTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapperTest.kt index 34da014..c510694 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapperTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinBlogEntryMapperTest.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinBlog import java.time.Instant import kotlin.test.Test diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapperTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapperTest.kt index e86a950..d762a76 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapperTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinWeeklyEntryMapperTest.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeekly import java.time.Instant import kotlin.test.Test diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapperTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapperTest.kt index 08bef1a..3c077f9 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapperTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/KotlinYouTubeEntryMapperTest.kt @@ -1,10 +1,10 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeAuthor -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.Link -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaCommunity -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaGroup +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeAuthor +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.Link +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaCommunity +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaGroup import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinYouTube import java.time.Instant import kotlin.test.Test diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapperTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapperTest.kt index 684daae..f7b14c4 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapperTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/mapper/TalkingKotlinEntryMapperTest.kt @@ -1,6 +1,6 @@ package io.github.reactivecircus.kstreamlined.backend.datafetcher.mapper -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinItem import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.TalkingKotlin import java.time.Instant import kotlin.test.Test diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoaderTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoaderTest.kt new file mode 100644 index 0000000..04d5449 --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/DataLoaderTest.kt @@ -0,0 +1,146 @@ +package io.github.reactivecircus.kstreamlined.backend.datasource + +import com.github.benmanes.caffeine.cache.Cache +import com.github.benmanes.caffeine.cache.Caffeine +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.builtins.serializer +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertFailsWith +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +class DataLoaderTest { + + private val localCache = Caffeine.newBuilder().build>() + + @Test + fun `returns data from local cache if present`() = runBlocking { + localCache.put("key", listOf(1, 2, 3)) + + val dataLoader = createDataLoader(localCache = localCache) + + val result = dataLoader.load("key") { + suspendCancellableCoroutine {} + } + + assert(result == listOf(1, 2, 3)) + } + + @Test + fun `returns data from remote cache if local cache is absent and remote cache is present`() = runBlocking { + val dataLoader = createDataLoader( + localCache = localCache, + redisMockEngine = MockEngine { respond(content = "{ \"result\": \"[1, 2, 3]\" }") }, + ) + + val result = dataLoader.load("key") { + suspendCancellableCoroutine {} + } + + assert(result == listOf(1, 2, 3)) + } + + @Test + fun `returns data from sot when both local and remote caches are absent and sot request succeeds`() = runBlocking { + val dataLoader = createDataLoader( + localCache = localCache, + redisMockEngine = MockEngine { respond(content = "{ \"result\": null }") }, + ) + + val result = dataLoader.load("key") { + listOf(1, 2, 3) + } + + assert(result == listOf(1, 2, 3)) + } + + @Test + fun `propagates exception when remote cache throws`(): Unit = runBlocking { + val dataLoader = createDataLoader( + localCache = localCache, + redisMockEngine = MockEngine { throw IOException("Unknown exception") }, + ) + + assertFailsWith { + dataLoader.load("key") { + suspendCancellableCoroutine {} + } + } + } + + @Test + fun `throws exception when sot request fails`(): Unit = runBlocking { + val dataLoader = createDataLoader( + localCache = localCache, + redisMockEngine = MockEngine { respond(content = "{ \"result\": null }") }, + ) + + assertFailsWith { + dataLoader.load("key") { + throw IOException("Server error") + } + } + } + + @Test + fun `updates local cache after loading data from remote cache`() = runBlocking { + val dataLoader = createDataLoader( + localCache = localCache, + redisMockEngine = MockEngine { respond(content = "{ \"result\": \"[1, 2, 3]\" }") }, + ) + + dataLoader.load("key") { + suspendCancellableCoroutine {} + } + + assert(localCache.getIfPresent("key") == listOf(1, 2, 3)) + } + + @Test + fun `updates both local and remote caches after loading data from sot`() = runBlocking { + val redisMockEngine = MockEngine { + if (it.url.encodedPath.contains("get")) { + respond(content = "{ \"result\": null }") + } else { + respond(content = "{ \"result\": \"OK\" }") + } + } + val dataLoader = createDataLoader( + localCache = localCache, + remoteCacheExpiry = 1.hours, + redisMockEngine = redisMockEngine, + ) + + dataLoader.load("key") { + listOf(1, 2, 3) + } + + assert(localCache.getIfPresent("key") == listOf(1, 2, 3)) + assert(redisMockEngine.requestHistory.last().url.pathSegments.last() == "key") + assert(redisMockEngine.requestHistory.last().url.encodedQuery == "EX=${1.hours.inWholeSeconds}") + assert(redisMockEngine.requestHistory.last().body.toString() == "TextContent[application/json] \"[1,2,3]\"") + assert(redisMockEngine.responseHistory.last().statusCode == HttpStatusCode.OK) + } + + private fun createDataLoader( + localCache: Cache>, + remoteCacheExpiry: Duration = 1.days, + redisMockEngine: MockEngine = MockEngine { respond(content = "{ \"result\": null }") }, + ) = DataLoader.of( + localCache = localCache, + remoteCacheExpiry = remoteCacheExpiry, + redisClient = RedisClient( + engine = redisMockEngine, + url = "", + token = "", + ), + serializer = Int.serializer() + ) +} diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeFeedClient.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeFeedDataSource.kt similarity index 75% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeFeedClient.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeFeedDataSource.kt index b422890..141b5d5 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeFeedClient.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeFeedDataSource.kt @@ -1,17 +1,15 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource -import com.github.benmanes.caffeine.cache.Cache -import com.github.benmanes.caffeine.cache.Caffeine -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeAuthor -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.Link -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaCommunity -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaGroup -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeAuthor +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.Link +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaCommunity +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaGroup +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinItem -object FakeFeedClient : FeedClient { +object FakeFeedDataSource : FeedDataSource { var nextKotlinBlogFeedResponse: () -> List = { listOf(DummyKotlinBlogItem) @@ -29,43 +27,23 @@ object FakeFeedClient : FeedClient { listOf(DummyKotlinWeeklyItem) } - context(CacheContext) override suspend fun loadKotlinBlogFeed(): List { return nextKotlinBlogFeedResponse() } - context(CacheContext) override suspend fun loadKotlinYouTubeFeed(): List { return nextKotlinYouTubeFeedResponse() } - context(CacheContext) override suspend fun loadTalkingKotlinFeed(): List { return nextTalkingKotlinFeedResponse() } - context(CacheContext) override suspend fun loadKotlinWeeklyFeed(): List { return nextKotlinWeeklyFeedResponse() } } -fun fakeKotlinBlogCacheContext() = object : CacheContext { - override val cache: Cache> = Caffeine.newBuilder().build() -} - -fun fakeKotlinYouTubeCacheContext() = object : CacheContext { - override val cache: Cache> = Caffeine.newBuilder().build() -} - -fun fakeTalkingKotlinCacheContext() = object : CacheContext { - override val cache: Cache> = Caffeine.newBuilder().build() -} - -fun fakeKotlinWeeklyCacheContext() = object : CacheContext { - override val cache: Cache> = Caffeine.newBuilder().build() -} - val DummyKotlinBlogItem = KotlinBlogItem( title = "A New Approach to Incremental Compilation in Kotlin", link = "https://blog.jetbrains.com/kotlin/2022/07/a-new-approach-to-incremental-compilation-in-kotlin/", diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeKotlinWeeklyIssueDataSource.kt similarity index 94% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeKotlinWeeklyIssueDataSource.kt index 88424a7..3255cc7 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FakeKotlinWeeklyIssueDataSource.kt @@ -1,9 +1,9 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryGroup -object FakeKotlinWeeklyIssueClient : KotlinWeeklyIssueClient { +object FakeKotlinWeeklyIssueDataSource : KotlinWeeklyIssueDataSource { var nextKotlinWeeklyIssueResponse: () -> List = { DummyKotlinWeeklyIssueEntries diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FullResponseParserTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FullResponseParserTest.kt similarity index 60% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FullResponseParserTest.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FullResponseParserTest.kt index 894d71c..20c09af 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FullResponseParserTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/FullResponseParserTest.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond @@ -7,6 +7,7 @@ import io.ktor.http.headersOf import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.runBlocking import kotlin.test.Test +import kotlin.time.Duration.Companion.seconds class FullResponseParserTest { @@ -22,6 +23,11 @@ class FullResponseParserTest { private val mockKotlinWeeklyRssResponse = javaClass.classLoader.getResource("kotlin_weekly_rss_response_full.xml")?.readText()!! + private val cacheConfig = DataLoader.CacheConfig( + localExpiry = 0.seconds, + remoteExpiry = 0.seconds, + ) + @Test fun `can parse Kotlin Blog RSS feed`() = runBlocking { val mockEngine = MockEngine { @@ -30,11 +36,14 @@ class FullResponseParserTest { headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) - with(fakeKotlinBlogCacheContext()) { - assert(feedClient.loadKotlinBlogFeed().size == 12) - } + assert(feedDataSource.loadKotlinBlogFeed().size == 12) } @Test @@ -45,11 +54,14 @@ class FullResponseParserTest { headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) - with(fakeKotlinYouTubeCacheContext()) { - assert(feedClient.loadKotlinYouTubeFeed().size == 15) - } + assert(feedDataSource.loadKotlinYouTubeFeed().size == 15) } @Test @@ -60,11 +72,14 @@ class FullResponseParserTest { headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) - with(fakeTalkingKotlinCacheContext()) { - assert(feedClient.loadTalkingKotlinFeed().size == 10) - } + assert(feedDataSource.loadTalkingKotlinFeed().size == 10) } @Test @@ -75,10 +90,13 @@ class FullResponseParserTest { headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) - with(fakeKotlinWeeklyCacheContext()) { - assert(feedClient.loadKotlinWeeklyFeed().size == 3) - } + assert(feedDataSource.loadKotlinWeeklyFeed().size == 3) } } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/NoOpRedisClient.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/NoOpRedisClient.kt new file mode 100644 index 0000000..7224fce --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/NoOpRedisClient.kt @@ -0,0 +1,17 @@ +package io.github.reactivecircus.kstreamlined.backend.datasource + +import io.github.reactivecircus.kstreamlined.backend.redis.RedisClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond + +val NoOpRedisClient = RedisClient( + engine = MockEngine { request -> + if (request.url.encodedPath.contains("get")) { + respond(content = "{ \"result\": null }") + } else { + respond(content = "{ \"result\": \"OK\" }") + } + }, + url = "", + token = "", +) diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealFeedClientTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealFeedDataSourceTest.kt similarity index 78% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealFeedClientTest.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealFeedDataSourceTest.kt index 04eed48..81986ed 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealFeedClientTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealFeedDataSourceTest.kt @@ -1,13 +1,13 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinBlogItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinWeeklyItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeAuthor -import io.github.reactivecircus.kstreamlined.backend.client.dto.KotlinYouTubeItem -import io.github.reactivecircus.kstreamlined.backend.client.dto.Link -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaCommunity -import io.github.reactivecircus.kstreamlined.backend.client.dto.MediaGroup -import io.github.reactivecircus.kstreamlined.backend.client.dto.TalkingKotlinItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinBlogItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinWeeklyItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeAuthor +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.KotlinYouTubeItem +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.Link +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaCommunity +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.MediaGroup +import io.github.reactivecircus.kstreamlined.backend.datasource.dto.TalkingKotlinItem import io.ktor.client.engine.mock.MockEngine import io.ktor.client.engine.mock.respond import io.ktor.client.engine.mock.respondError @@ -19,8 +19,9 @@ import io.ktor.utils.io.ByteReadChannel import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertFailsWith +import kotlin.time.Duration.Companion.seconds -class RealFeedClientTest { +class RealFeedDataSourceTest { private val mockKotlinBlogRssResponse = javaClass.classLoader.getResource("kotlin_blog_rss_response_sample.xml")?.readText()!! @@ -34,15 +35,25 @@ class RealFeedClientTest { private val mockKotlinWeeklyRssResponse = javaClass.classLoader.getResource("kotlin_weekly_rss_response_sample.xml")?.readText()!! + private val cacheConfig = DataLoader.CacheConfig( + localExpiry = 0.seconds, + remoteExpiry = 0.seconds, + ) + @Test - fun `loadKotlinBlogFeed() returns KotlinBlogItems when API call was successful`() = runBlocking { + fun `loadKotlinBlogFeed() returns KotlinBlogItems when API call succeeds`() = runBlocking { val mockEngine = MockEngine { respond( content = ByteReadChannel(mockKotlinBlogRssResponse), headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) val expected = listOf( KotlinBlogItem( @@ -63,34 +74,40 @@ class RealFeedClientTest { ), ) - with(fakeKotlinBlogCacheContext()) { - assert(feedClient.loadKotlinBlogFeed() == expected) - } + assert(feedDataSource.loadKotlinBlogFeed() == expected) } @Test - fun `loadKotlinBlogFeed() throws exception when API call failed`(): Unit = runBlocking { + fun `loadKotlinBlogFeed() throws exception when API call fails`(): Unit = runBlocking { val mockEngine = MockEngine { respondError(HttpStatusCode.RequestTimeout) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) assertFailsWith { - with(fakeKotlinBlogCacheContext()) { - feedClient.loadKotlinBlogFeed() - } + feedDataSource.loadKotlinBlogFeed() } } @Test - fun `loadKotlinYouTubeFeed() returns KotlinYouTubeItems when API call was successful`() = runBlocking { + fun `loadKotlinYouTubeFeed() returns KotlinYouTubeItems when API call succeeds`() = runBlocking { val mockEngine = MockEngine { respond( content = ByteReadChannel(mockKotlinYouTubeRssResponse), headers = headersOf(HttpHeaders.ContentType, "application/xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) val expected = listOf( KotlinYouTubeItem( @@ -175,34 +192,40 @@ class RealFeedClientTest { ), ) - with(fakeKotlinYouTubeCacheContext()) { - assert(feedClient.loadKotlinYouTubeFeed() == expected) - } + assert(feedDataSource.loadKotlinYouTubeFeed() == expected) } @Test - fun `loadKotlinYouTubeFeed() throws exception when API call failed`(): Unit = runBlocking { + fun `loadKotlinYouTubeFeed() throws exception when API call fails`(): Unit = runBlocking { val mockEngine = MockEngine { respondError(HttpStatusCode.RequestTimeout) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) assertFailsWith { - with(fakeKotlinYouTubeCacheContext()) { - feedClient.loadKotlinYouTubeFeed() - } + feedDataSource.loadKotlinYouTubeFeed() } } @Test - fun `loadTalkingKotlinFeed() returns TalkingKotlinItems when API call was successful`() = runBlocking { + fun `loadTalkingKotlinFeed() returns TalkingKotlinItems when API call succeeds`() = runBlocking { val mockEngine = MockEngine { respond( content = ByteReadChannel(mockTalkingKotlinRssResponse), headers = headersOf(HttpHeaders.ContentType, "application/rss+xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) val expected = listOf( TalkingKotlinItem( @@ -227,34 +250,40 @@ class RealFeedClientTest { ), ) - with(fakeTalkingKotlinCacheContext()) { - assert(feedClient.loadTalkingKotlinFeed() == expected) - } + assert(feedDataSource.loadTalkingKotlinFeed() == expected) } @Test - fun `loadTalkingKotlinFeed() throws exception when API call failed`(): Unit = runBlocking { + fun `loadTalkingKotlinFeed() throws exception when API call fails`(): Unit = runBlocking { val mockEngine = MockEngine { respondError(HttpStatusCode.RequestTimeout) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) assertFailsWith { - with(fakeTalkingKotlinCacheContext()) { - feedClient.loadTalkingKotlinFeed() - } + feedDataSource.loadTalkingKotlinFeed() } } @Test - fun `loadKotlinWeeklyFeed() returns KotlinWeeklyItems when API call was successful`() = runBlocking { + fun `loadKotlinWeeklyFeed() returns KotlinWeeklyItems when API call succeeds`() = runBlocking { val mockEngine = MockEngine { respond( content = ByteReadChannel(mockKotlinWeeklyRssResponse), headers = headersOf(HttpHeaders.ContentType, "text/xml") ) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) val expected = listOf( KotlinWeeklyItem( @@ -271,22 +300,23 @@ class RealFeedClientTest { ), ) - with(fakeKotlinWeeklyCacheContext()) { - assert(feedClient.loadKotlinWeeklyFeed() == expected) - } + assert(feedDataSource.loadKotlinWeeklyFeed() == expected) } @Test - fun `loadKotlinWeeklyFeed() throws exception when API call failed`(): Unit = runBlocking { + fun `loadKotlinWeeklyFeed() throws exception when API call fails`(): Unit = runBlocking { val mockEngine = MockEngine { respondError(HttpStatusCode.RequestTimeout) } - val feedClient = RealFeedClient(mockEngine, TestClientConfigs) + val feedDataSource = RealFeedDataSource( + engine = mockEngine, + dataSourceConfig = TestFeedDataSourceConfig, + cacheConfig = cacheConfig, + redisClient = NoOpRedisClient, + ) assertFailsWith { - with(fakeKotlinWeeklyCacheContext()) { - feedClient.loadKotlinWeeklyFeed() - } + feedDataSource.loadKotlinWeeklyFeed() } } } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealKotlinWeeklyIssueDataSourceTest.kt similarity index 92% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealKotlinWeeklyIssueDataSourceTest.kt index 829325b..fb54afc 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/RealKotlinWeeklyIssueDataSourceTest.kt @@ -1,4 +1,4 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryGroup @@ -12,17 +12,17 @@ import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertFailsWith -class RealKotlinWeeklyIssueClientTest { +class RealKotlinWeeklyIssueDataSourceTest { private val mockKotlinWeeklyIssueResponse = javaClass.classLoader.getResource("kotlin_weekly_issue_sample.html")?.readText()!! @Test - fun `loadKotlinWeeklyIssue(url) returns KotlinWeeklyIssueEntry when API call was successful`() = runBlocking { + fun `loadKotlinWeeklyIssue(url) returns KotlinWeeklyIssueEntry when API call succeeds`() = runBlocking { val mockEngine = MockEngine { respond(content = ByteReadChannel(mockKotlinWeeklyIssueResponse)) } - val kotlinWeeklyIssueClient = RealKotlinWeeklyIssueClient(mockEngine) + val kotlinWeeklyIssueDataSource = RealKotlinWeeklyIssueDataSource(mockEngine) val expected = listOf( KotlinWeeklyIssueEntry( @@ -111,18 +111,18 @@ class RealKotlinWeeklyIssueClientTest { ), ) - assert(kotlinWeeklyIssueClient.loadKotlinWeeklyIssue("url") == expected) + assert(kotlinWeeklyIssueDataSource.loadKotlinWeeklyIssue("url") == expected) } @Test - fun `loadKotlinWeeklyIssue(url) throws exception when API call failed`(): Unit = runBlocking { + fun `loadKotlinWeeklyIssue(url) throws exception when API call fails`(): Unit = runBlocking { val mockEngine = MockEngine { respondError(HttpStatusCode.RequestTimeout) } - val kotlinWeeklyIssueClient = RealKotlinWeeklyIssueClient(mockEngine) + val kotlinWeeklyIssueDataSource = RealKotlinWeeklyIssueDataSource(mockEngine) assertFailsWith { - kotlinWeeklyIssueClient.loadKotlinWeeklyIssue("url") + kotlinWeeklyIssueDataSource.loadKotlinWeeklyIssue("url") } } } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/TestClientConfigs.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/TestFeedDataSourceConfig.kt similarity index 51% rename from src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/TestClientConfigs.kt rename to src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/TestFeedDataSourceConfig.kt index 340eb9e..5ee1a37 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/TestClientConfigs.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datasource/TestFeedDataSourceConfig.kt @@ -1,6 +1,6 @@ -package io.github.reactivecircus.kstreamlined.backend.client +package io.github.reactivecircus.kstreamlined.backend.datasource -val TestClientConfigs = ClientConfigs( +val TestFeedDataSourceConfig = FeedDataSourceConfig( kotlinBlogFeedUrl = "", kotlinYouTubeFeedUrl = "", talkingKotlinFeedUrl = "", diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClientTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClientTest.kt new file mode 100644 index 0000000..4508220 --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/redis/RedisClientTest.kt @@ -0,0 +1,112 @@ +package io.github.reactivecircus.kstreamlined.backend.redis + +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.http.HttpStatusCode +import kotlinx.coroutines.runBlocking +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class RedisClientTest { + + @Test + fun `get(key) returns result when API call succeeds`() = runBlocking { + val mockEngine = MockEngine { request -> + when (request.url.pathSegments.last()) { + "a" -> respond(content = "{ \"result\": null }") + "b" -> respond(content = "{ \"result\": 3 }") + "c" -> respond(content = "{ \"result\": \"foo\" }") + "d" -> respond( + content = """ + { "result": "[\"a\",\"b\",\"c\"]" } + """.trimIndent() + ) + + else -> respond( + content = """ + { "result": "{\"key\": \"value\"}" } + """.trimIndent() + ) + } + } + val redisClient = createRedisClient(mockEngine) + + assert(redisClient.get("a") == null) + assert(redisClient.get("b") == "3") + assert(redisClient.get("c") == "foo") + assert(redisClient.get("d") == "[\"a\",\"b\",\"c\"]") + assert(redisClient.get("e") == "{\"key\": \"value\"}") + } + + @Test + fun `get(key) returns null when API call fails`() = runBlocking { + val mockEngine = MockEngine { + respondError(HttpStatusCode.RequestTimeout) + } + val redisClient = createRedisClient(mockEngine) + + assert(redisClient.get("a") == null) + } + + @Test + fun `get(key) throws any unknown exceptions`(): Unit = runBlocking { + val mockEngine = MockEngine { + throw IOException("Unknown exception") + } + val redisClient = createRedisClient(mockEngine) + + assertFailsWith { + redisClient.get("a") + } + } + + @Test + fun `set(key, value) calls API with expected key, value, and expiry parameter`() = runBlocking { + val mockEngine = MockEngine { + respond(content = "{ \"result\": \"OK\" }") + } + val redisClient = createRedisClient(mockEngine) + + redisClient.set("a", "3", keyExpirySeconds = 10) + + assert(mockEngine.requestHistory[0].url.pathSegments.last() == "a") + assert(mockEngine.requestHistory[0].url.encodedQuery == "EX=10") + assert(mockEngine.requestHistory[0].body.toString() == "TextContent[application/json] \"3\"") + assert(mockEngine.responseHistory[0].statusCode == HttpStatusCode.OK) + } + + @Test + fun `set(key, value) does not throw when API call fails`() = runBlocking { + val mockEngine = MockEngine { + respondError(HttpStatusCode.InternalServerError) + } + val redisClient = createRedisClient(mockEngine) + + redisClient.set("a", "3") + + assert(mockEngine.requestHistory[0].url.pathSegments.last() == "a") + assert(mockEngine.requestHistory[0].url.encodedQuery == "EX=3600") + assert(mockEngine.requestHistory[0].body.toString() == "TextContent[application/json] \"3\"") + assert(mockEngine.responseHistory[0].statusCode == HttpStatusCode.InternalServerError) + } + + @Test + fun `set(key, value) throws any unknown exceptions`(): Unit = runBlocking { + val mockEngine = MockEngine { + throw IOException("Unknown exception") + } + val redisClient = createRedisClient(mockEngine) + + assertFailsWith { + redisClient.set("a", "3") + } + } + + private fun createRedisClient(mockEngine: MockEngine) = RedisClient( + engine = mockEngine, + url = "", + token = "", + ) +}