From f0c0cfc0f1962efca08393c1c7924e161de90db4 Mon Sep 17 00:00:00 2001 From: Yang Date: Tue, 26 Dec 2023 01:50:39 +1100 Subject: [PATCH] Add query for loading kotlin weekly entries by url. --- build.gradle.kts | 4 + gradle/libs.versions.toml | 8 + .../kstreamlined/backend/KSConfiguration.kt | 24 +- .../backend/client/KotlinWeeklyIssueClient.kt | 96 ++ .../datafetcher/FeedEntryDataFetcher.kt | 10 +- .../KotlinWeeklyIssueDataFetcher.kt | 28 + .../resources/schema/kstreamlined.graphqls | 28 + .../backend/TestKSConfiguration.kt | 7 + .../client/FakeKotlinWeeklyIssueClient.kt | 53 + .../client/RealKotlinWeeklyIssueClientTest.kt | 129 +++ .../KotlinWeeklyIssueDataFetcherTest.kt | 81 ++ .../resources/kotlin_weekly_issue_sample.html | 919 ++++++++++++++++++ 12 files changed, 1380 insertions(+), 7 deletions(-) create mode 100644 src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt create mode 100644 src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt create mode 100644 src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt create mode 100644 src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt create mode 100644 src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt create mode 100644 src/test/resources/kotlin_weekly_issue_sample.html diff --git a/build.gradle.kts b/build.gradle.kts index 88fc10b..58339ad 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -45,7 +45,11 @@ dependencies { implementation(libs.ktor.serialization.xml) implementation(libs.apacheCommonsText) implementation(libs.apacheCommonsLang3) + implementation(libs.apacheCommonsNet) implementation(libs.caffeine) + implementation(libs.scrapeit) + implementation(libs.jsoup) + implementation(libs.xalan) testImplementation(libs.spring.boot.starter.test) testImplementation(kotlin("test")) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cdae94a..9a3c911 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,7 +9,11 @@ dgsCodegen = "6.1.1" detekt = "1.23.4" apacheCommonsText = "1.11.0" apacheCommonsLang3 = "3.14.0" +apacheCommonsNet = "3.10.0" caffeine = "3.1.8" +scrapeit = "1.3.0-alpha.2" +jsoup = "1.17.1" +xalan = "2.7.3" toolchainsResolver = "0.7.0" [plugins] @@ -35,4 +39,8 @@ dgs-bom = { module = "com.netflix.graphql.dgs:graphql-dgs-platform-dependencies" dgs-starter = { module = "com.netflix.graphql.dgs:graphql-dgs-webflux-starter"} apacheCommonsText = { module = "org.apache.commons:commons-text", version.ref = "apacheCommonsText" } apacheCommonsLang3 = { module = "org.apache.commons:commons-lang3", version.ref = "apacheCommonsLang3" } +apacheCommonsNet = { module = "commons-net:commons-net", version.ref = "apacheCommonsNet" } caffeine = { module = "com.github.ben-manes.caffeine:caffeine", version.ref = "caffeine" } +scrapeit = { module = "it.skrape:skrapeit", version.ref = "scrapeit" } +jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" } +xalan = { module = "xalan:xalan", version.ref = "xalan" } 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 2bf4bae..d294857 100644 --- a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/KSConfiguration.kt @@ -2,7 +2,10 @@ 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.ktor.client.engine.HttpClientEngine import io.ktor.client.engine.cio.CIO import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean @@ -12,13 +15,30 @@ import org.springframework.context.annotation.Configuration class KSConfiguration { @Bean - fun feedClient(clientConfigs: ClientConfigs): FeedClient { + fun feedClient( + engine: HttpClientEngine, + clientConfigs: ClientConfigs, + ): FeedClient { return RealFeedClient( - engine = CIO.create(), + engine = engine, clientConfigs = clientConfigs, ) } + @Bean + fun kotlinWeeklyIssueClient( + engine: HttpClientEngine + ): KotlinWeeklyIssueClient { + return RealKotlinWeeklyIssueClient( + engine = engine, + ) + } + + @Bean + fun httpClientEngine(): HttpClientEngine { + return CIO.create() + } + @Bean fun clientConfigs( @Value("\${ks.kotlin-blog-feed-url}") kotlinBlogFeedUrl: String, diff --git a/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt new file mode 100644 index 0000000..43e9e97 --- /dev/null +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/client/KotlinWeeklyIssueClient.kt @@ -0,0 +1,96 @@ +package io.github.reactivecircus.kstreamlined.backend.client + +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryType +import io.ktor.client.HttpClient +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.get +import io.ktor.client.statement.bodyAsText +import it.skrape.core.htmlDocument +import it.skrape.selects.eachText +import it.skrape.selects.html5.a +import it.skrape.selects.html5.div +import it.skrape.selects.html5.span + +interface KotlinWeeklyIssueClient { + suspend fun loadKotlinWeeklyIssue(url: String): List +} + +class RealKotlinWeeklyIssueClient( + engine: HttpClientEngine, +) : KotlinWeeklyIssueClient { + + private val httpClient = HttpClient(engine) { + expectSuccess = true + install(HttpTimeout) { + connectTimeoutMillis = HttpTimeoutMillis + requestTimeoutMillis = HttpTimeoutMillis + } + } + + override suspend fun loadKotlinWeeklyIssue(url: String): List { + return buildList { + htmlDocument(httpClient.get(url).bodyAsText()) { + div { + withAttribute = "style" to "overflow: hidden;" + findAll { + forEach { section -> + val type = section.div { + findFirst { text } + }.uppercase().let { + KotlinWeeklyIssueEntryType.entries.find { type -> it == type.name } ?: return@forEach + } + + val titleWithLinkPairs = mutableListOf>() + val summaries = mutableListOf() + val sources = mutableListOf() + + section.div { + findSecond { + a { + findAll { + forEach { + it.eachHref.first().let { url -> + if (it.text != url) { + titleWithLinkPairs.add(it.text to url) + } else { + sources.add(url) + } + } + } + } + } + span { + withAttribute = "style" to "font-size:14px" + findAll { + eachText.forEach { + summaries.add(it) + } + } + } + } + } + + titleWithLinkPairs.forEachIndexed { index, pair -> + add( + KotlinWeeklyIssueEntry( + type = type, + title = pair.first, + url = pair.second, + summary = summaries[index], + source = sources[index], + ) + ) + } + } + } + } + } + } + } + + companion object { + private const val HttpTimeoutMillis = 10_000L + } +} 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 d2954e3..e474873 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 @@ -32,7 +32,7 @@ import java.time.Duration @DgsComponent class FeedEntryDataFetcher( - private val feedClient: FeedClient, + private val client: FeedClient, private val coroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, ) { private val kotlinBlogCacheContext = object : CacheContext { @@ -71,19 +71,19 @@ class FeedEntryDataFetcher( async(coroutineDispatcher) { when (source) { FeedSourceKey.KOTLIN_BLOG -> with(kotlinBlogCacheContext) { - feedClient.loadKotlinBlogFeed().map { it.toKotlinBlogEntry() } + client.loadKotlinBlogFeed().map { it.toKotlinBlogEntry() } } FeedSourceKey.KOTLIN_YOUTUBE_CHANNEL -> with(kotlinYouTubeCacheContext) { - feedClient.loadKotlinYouTubeFeed().map { it.toKotlinYouTubeEntry() } + client.loadKotlinYouTubeFeed().map { it.toKotlinYouTubeEntry() } } FeedSourceKey.TALKING_KOTLIN_PODCAST -> with(talkingKotlinCacheContext) { - feedClient.loadTalkingKotlinFeed().map { it.toTalkingKotlinEntry() } + client.loadTalkingKotlinFeed().map { it.toTalkingKotlinEntry() } } FeedSourceKey.KOTLIN_WEEKLY -> with(kotlinWeeklyCacheContext) { - feedClient.loadKotlinWeeklyFeed().map { it.toKotlinWeeklyEntry() } + client.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 new file mode 100644 index 0000000..d0b42fd --- /dev/null +++ b/src/main/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcher.kt @@ -0,0 +1,28 @@ +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.InputArgument +import io.github.reactivecircus.kstreamlined.backend.client.KotlinWeeklyIssueClient +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 cache: Cache> = Caffeine + .newBuilder() + .expireAfterAccess(Duration.ofHours(1)) + .build() + + @DgsQuery(field = DgsConstants.QUERY.KotlinWeeklyIssue) + suspend fun kotlinWeeklyIssue(@InputArgument url: String): List { + return cache.getIfPresent(url) ?: client.loadKotlinWeeklyIssue(url).also { + cache.put(url, it) + } + } +} diff --git a/src/main/resources/schema/kstreamlined.graphqls b/src/main/resources/schema/kstreamlined.graphqls index 425bb5d..213a538 100644 --- a/src/main/resources/schema/kstreamlined.graphqls +++ b/src/main/resources/schema/kstreamlined.graphqls @@ -3,6 +3,8 @@ type Query { feedEntries(filters: [FeedSourceKey!] = null): [FeedEntry!]! "Returns list of all available feed sources." feedSources: [FeedSource!]! + "Returns list of entries for a Kotlin Weekly issue." + kotlinWeeklyIssue(url: String!): [KotlinWeeklyIssueEntry]! } type FeedSource { @@ -96,5 +98,31 @@ type KotlinWeekly implements FeedEntry { contentUrl: String! } +type KotlinWeeklyIssueEntry { + "Title of the issue entry." + title: String! + "Summary of the issue entry." + summary: String! + "Url of the issue entry." + url: String! + "Url of the issue entry source." + source: String! + "Type of the issue entry." + type: KotlinWeeklyIssueEntryType! +} + +enum KotlinWeeklyIssueEntryType { + "Announcements." + ANNOUNCEMENTS + "Articles." + ARTICLES + "Android." + ANDROID + "Videos." + VIDEOS + "Libraries." + LIBRARIES +} + "ISO 8601 instant." scalar Instant 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 08eef12..678ec1f 100644 --- a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/TestKSConfiguration.kt @@ -1,7 +1,9 @@ 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 org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -12,4 +14,9 @@ class TestKSConfiguration { fun feedClient(): FeedClient { return FakeFeedClient } + + @Bean + fun kotlinWeeklyIssueClient(): KotlinWeeklyIssueClient { + return FakeKotlinWeeklyIssueClient + } } diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt new file mode 100644 index 0000000..5511d51 --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/FakeKotlinWeeklyIssueClient.kt @@ -0,0 +1,53 @@ +package io.github.reactivecircus.kstreamlined.backend.client + +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryType + +object FakeKotlinWeeklyIssueClient : KotlinWeeklyIssueClient { + + var nextKotlinWeeklyIssueResponse: () -> List = { + DummyKotlinWeeklyIssueEntries + } + + override suspend fun loadKotlinWeeklyIssue(url: String): List { + return nextKotlinWeeklyIssueResponse() + } +} + +val DummyKotlinWeeklyIssueEntries = listOf( + KotlinWeeklyIssueEntry( + title = "Amper Update – December 2023", + summary = "Last month JetBrains introduced Amper, a tool to improve the project configuration user experience. Marton Braun gives us an update about its state in December 2023.", + url = "https://blog.jetbrains.com/amper/2023/12/amper-update-december-2023/", + source = "blog.jetbrains.com", + type = KotlinWeeklyIssueEntryType.ANNOUNCEMENTS, + ), + KotlinWeeklyIssueEntry( + title = "How to Use the Cucumber Framework to Test Application Use Cases", + summary = "Matthias Schenk writes today about Cucumber, a framework that can be used in application development to verify the correct behavior of the application.", + url = "https://towardsdev.com/how-to-use-the-cucumber-framework-to-test-application-use-cases-48b4f21ee0d0", + source = "towardsdev.com", + type = KotlinWeeklyIssueEntryType.ARTICLES, + ), + KotlinWeeklyIssueEntry( + title = "Using launcher and themed icons in Android Studio, the manual way", + summary = "Marlon Lòpez describes how we can use the launcher and the themed icons in Android Studio.", + url = "https://dev.to/marlonlom/using-launcher-and-themed-icons-in-android-studio-the-manual-way-1h2a", + source = "dev.to", + type = KotlinWeeklyIssueEntryType.ANDROID, + ), + KotlinWeeklyIssueEntry( + title = "Setting Sail with Compose Multiplatform by Isuru Rajapakse", + summary = "Isuru Rajapakse talks at the DevFest Sri Lanka about Compose Multiplatform.", + url = "https://www.youtube.com/watch?v=sG60644C47I", + source = "www.youtube.com", + type = KotlinWeeklyIssueEntryType.VIDEOS, + ), + KotlinWeeklyIssueEntry( + title = "Kim - Kotlin Image Metadata", + summary = "Kim is a Kotlin image metadata manipulation library for Kotlin Multiplatform.", + url = "https://github.com/Ashampoo/kim", + source = "github.com", + type = KotlinWeeklyIssueEntryType.LIBRARIES, + ), +) diff --git a/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt new file mode 100644 index 0000000..e15a1e1 --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/client/RealKotlinWeeklyIssueClientTest.kt @@ -0,0 +1,129 @@ +package io.github.reactivecircus.kstreamlined.backend.client + +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntry +import io.github.reactivecircus.kstreamlined.backend.schema.generated.types.KotlinWeeklyIssueEntryType +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.engine.mock.respondError +import io.ktor.client.plugins.ClientRequestException +import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals + +class RealKotlinWeeklyIssueClientTest { + + private val mockKotlinWeeklyIssueResponse = + javaClass.classLoader.getResource("kotlin_weekly_issue_sample.html")?.readText()!! + + @Test + fun `loadKotlinWeeklyIssue(url) returns KotlinWeeklyIssueEntry when API call was successful`() = runBlocking { + val mockEngine = MockEngine { + respond(content = ByteReadChannel(mockKotlinWeeklyIssueResponse)) + } + val kotlinWeeklyIssueClient = RealKotlinWeeklyIssueClient(mockEngine) + + val expected = listOf( + KotlinWeeklyIssueEntry( + title = "Amper Update – December 2023", + summary = "Last month JetBrains introduced Amper, a tool to improve the project configuration user experience. Marton Braun gives us an update about its state in December 2023.", + url = "https://blog.jetbrains.com/amper/2023/12/amper-update-december-2023/", + source = "blog.jetbrains.com", + type = KotlinWeeklyIssueEntryType.ANNOUNCEMENTS, + ), + KotlinWeeklyIssueEntry( + title = "Koin Wrapped- Recapping the 2023 Milestones of Our Kotlin Integration Framework", + summary = "In this post, the Koin crew wraps up all the milestones and roadmap achieved in 2023.", + url = "https://blog.cloud-inject.io/koin-2023-highlights", + source = "blog.cloud-inject.io", + type = KotlinWeeklyIssueEntryType.ANNOUNCEMENTS, + ), + KotlinWeeklyIssueEntry( + title = "How to Use the Cucumber Framework to Test Application Use Cases", + summary = "Matthias Schenk writes today about Cucumber, a framework that can be used in application development to verify the correct behavior of the application.", + url = "https://towardsdev.com/how-to-use-the-cucumber-framework-to-test-application-use-cases-48b4f21ee0d0", + source = "towardsdev.com", + type = KotlinWeeklyIssueEntryType.ARTICLES, + ), + KotlinWeeklyIssueEntry( + title = "Jetpack Preferences DataStore in Kotlin Multiplatform (KMP)", + summary = "FunkyMuse put up an article showcasing how to read and write preferences on multiple platforms when using the KMP DataStore library.", + url = "https://funkymuse.dev/posts/create-data-store-kmp/", + source = "funkymuse.dev", + type = KotlinWeeklyIssueEntryType.ARTICLES, + ), + KotlinWeeklyIssueEntry( + title = "Using launcher and themed icons in Android Studio, the manual way", + summary = "Marlon Lòpez describes how we can use the launcher and the themed icons in Android Studio.", + url = "https://dev.to/marlonlom/using-launcher-and-themed-icons-in-android-studio-the-manual-way-1h2a", + source = "dev.to", + type = KotlinWeeklyIssueEntryType.ANDROID, + ), + KotlinWeeklyIssueEntry( + title = "Setting Sail with Compose Multiplatform by Isuru Rajapakse", + summary = "Isuru Rajapakse talks at the DevFest Sri Lanka about Compose Multiplatform.", + url = "https://www.youtube.com/watch?v=sG60644C47I", + source = "www.youtube.com", + type = KotlinWeeklyIssueEntryType.VIDEOS, + ), + KotlinWeeklyIssueEntry( + title = "Developer Experience and Kotlin Lenses", + summary = "In his next video, Duncan McGregor keeps talking about Developer Experience and Kotlin lenses.", + url = "https://www.youtube.com/watch?v=htvpwOKYhNs", + source = "www.youtube.com", + type = KotlinWeeklyIssueEntryType.VIDEOS, + ), + KotlinWeeklyIssueEntry( + title = "Network-Resilient Applications with Store5 | Talking Kotlin", + summary = "In this chapter of Talking Kotlin, Mike Nakhimovich, Yigit Boyar, and Matthew Ramotar talk about Store, a Kotlin Multiplatform library for building network-resilient applications.", + url = "https://www.youtube.com/watch?v=a32Otwx7c0w", + source = "www.youtube.com", + type = KotlinWeeklyIssueEntryType.VIDEOS, + ), + KotlinWeeklyIssueEntry( + title = "Kim - Kotlin Image Metadata", + summary = "Kim is a Kotlin image metadata manipulation library for Kotlin Multiplatform.", + url = "https://github.com/Ashampoo/kim", + source = "github.com", + type = KotlinWeeklyIssueEntryType.LIBRARIES, + ), + KotlinWeeklyIssueEntry( + title = "DiKTat", + summary = "DiKTat is a strict coding standard for Kotlin, consisting of a collection of Kotlin code style rules implemented as Abstract Syntax Tree (AST) visitors built on top of KTlint.", + url = "https://github.com/saveourtool/diktat", + source = "github.com", + type = KotlinWeeklyIssueEntryType.LIBRARIES, + ), + KotlinWeeklyIssueEntry( + title = "FailGood", + summary = "Failgood is a test runner for Kotlin focusing on simplicity, usability and speed.", + url = "https://github.com/failgood/failgood", + source = "github.com", + type = KotlinWeeklyIssueEntryType.LIBRARIES, + ), + KotlinWeeklyIssueEntry( + title = "exif-viewer", + summary = "Free online EXIF Viewer built with Kotlin/WASM.", + url = "https://github.com/StefanOltmann/exif-viewer", + source = "github.com", + type = KotlinWeeklyIssueEntryType.LIBRARIES, + ), + ) + + assertEquals(expected, kotlinWeeklyIssueClient.loadKotlinWeeklyIssue("url")) + } + + @Test + fun `loadKotlinWeeklyIssue(url) throws exception when API call failed`(): Unit = runBlocking { + val mockEngine = MockEngine { + respondError(HttpStatusCode.RequestTimeout) + } + val kotlinWeeklyIssueClient = RealKotlinWeeklyIssueClient(mockEngine) + + assertThrows { + kotlinWeeklyIssueClient.loadKotlinWeeklyIssue("url") + } + } +} 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 new file mode 100644 index 0000000..9509721 --- /dev/null +++ b/src/test/kotlin/io/github/reactivecircus/kstreamlined/backend/datafetcher/KotlinWeeklyIssueDataFetcherTest.kt @@ -0,0 +1,81 @@ +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 org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.ContextConfiguration +import kotlin.test.assertEquals + +@SpringBootTest(classes = [DgsAutoConfiguration::class, KotlinWeeklyIssueDataFetcher::class, InstantScalar::class]) +@ContextConfiguration(classes = [TestKSConfiguration::class]) +class KotlinWeeklyIssueDataFetcherTest { + + @Autowired + private lateinit var dgsQueryExecutor: DgsQueryExecutor + + @Autowired + private lateinit var kotlinWeeklyIssueClient: KotlinWeeklyIssueClient + + private val kotlinWeeklyIssueQuery = """ + query KotlinWeeklyIssue(${"$"}url: String!) { + kotlinWeeklyIssue(url: ${"$"}url) { + title + summary + url + source + type + } + } + """.trimIndent() + + @Test + fun `kotlinWeeklyIssue(url) query returns expected kotlin weekly issue entries when operation was successful`() { + (kotlinWeeklyIssueClient as FakeKotlinWeeklyIssueClient).nextKotlinWeeklyIssueResponse = { + DummyKotlinWeeklyIssueEntries + } + + val context = dgsQueryExecutor.executeAndGetDocumentContext( + kotlinWeeklyIssueQuery, + mapOf("url" to "https://mailchi.mp/kotlinweekly/kotlin-weekly-386"), + ) + + assertEquals(5, context.read("data.kotlinWeeklyIssue.size()")) + + assertEquals(DummyKotlinWeeklyIssueEntries[0].title, context.read("data.kotlinWeeklyIssue[0].title")) + assertEquals(DummyKotlinWeeklyIssueEntries[0].summary, context.read("data.kotlinWeeklyIssue[0].summary")) + assertEquals(DummyKotlinWeeklyIssueEntries[0].url, context.read("data.kotlinWeeklyIssue[0].url")) + assertEquals(DummyKotlinWeeklyIssueEntries[0].source, context.read("data.kotlinWeeklyIssue[0].source")) + assertEquals(DummyKotlinWeeklyIssueEntries[0].type.name, context.read("data.kotlinWeeklyIssue[0].type")) + + assertEquals(DummyKotlinWeeklyIssueEntries[1].title, context.read("data.kotlinWeeklyIssue[1].title")) + assertEquals(DummyKotlinWeeklyIssueEntries[1].summary, context.read("data.kotlinWeeklyIssue[1].summary")) + assertEquals(DummyKotlinWeeklyIssueEntries[1].url, context.read("data.kotlinWeeklyIssue[1].url")) + assertEquals(DummyKotlinWeeklyIssueEntries[1].source, context.read("data.kotlinWeeklyIssue[1].source")) + assertEquals(DummyKotlinWeeklyIssueEntries[1].type.name, context.read("data.kotlinWeeklyIssue[1].type")) + + assertEquals(DummyKotlinWeeklyIssueEntries[2].title, context.read("data.kotlinWeeklyIssue[2].title")) + assertEquals(DummyKotlinWeeklyIssueEntries[2].summary, context.read("data.kotlinWeeklyIssue[2].summary")) + assertEquals(DummyKotlinWeeklyIssueEntries[2].url, context.read("data.kotlinWeeklyIssue[2].url")) + assertEquals(DummyKotlinWeeklyIssueEntries[2].source, context.read("data.kotlinWeeklyIssue[2].source")) + assertEquals(DummyKotlinWeeklyIssueEntries[2].type.name, context.read("data.kotlinWeeklyIssue[2].type")) + + assertEquals(DummyKotlinWeeklyIssueEntries[3].title, context.read("data.kotlinWeeklyIssue[3].title")) + assertEquals(DummyKotlinWeeklyIssueEntries[3].summary, context.read("data.kotlinWeeklyIssue[3].summary")) + assertEquals(DummyKotlinWeeklyIssueEntries[3].url, context.read("data.kotlinWeeklyIssue[3].url")) + assertEquals(DummyKotlinWeeklyIssueEntries[3].source, context.read("data.kotlinWeeklyIssue[3].source")) + assertEquals(DummyKotlinWeeklyIssueEntries[3].type.name, context.read("data.kotlinWeeklyIssue[3].type")) + + assertEquals(DummyKotlinWeeklyIssueEntries[4].title, context.read("data.kotlinWeeklyIssue[4].title")) + assertEquals(DummyKotlinWeeklyIssueEntries[4].summary, context.read("data.kotlinWeeklyIssue[4].summary")) + assertEquals(DummyKotlinWeeklyIssueEntries[4].url, context.read("data.kotlinWeeklyIssue[4].url")) + assertEquals(DummyKotlinWeeklyIssueEntries[4].source, context.read("data.kotlinWeeklyIssue[4].source")) + assertEquals(DummyKotlinWeeklyIssueEntries[4].type.name, context.read("data.kotlinWeeklyIssue[4].type")) + } +} diff --git a/src/test/resources/kotlin_weekly_issue_sample.html b/src/test/resources/kotlin_weekly_issue_sample.html new file mode 100644 index 0000000..4f73b6e --- /dev/null +++ b/src/test/resources/kotlin_weekly_issue_sample.html @@ -0,0 +1,919 @@ + + + + + + + + + + + + + + Kotlin Weekly #386 + + + + + + + + + +
+ + + + +
+ + + + + + + + + + + + + + + +
+ + + + + + +
+ + + + + + + + + +
+ +
+   +
+
 
+
+
+
+
+ + + + + + +
+ + + + + + + + +
+

ISSUE #386

+
24th of December 2023
+   +
+
+
+


+
+ Announcements +

+
+

+ Amper Update – December 2023
+ Last month JetBrains introduced Amper, a tool to improve the project configuration user experience. Marton Braun gives us an update about its state in December 2023.
+ blog.jetbrains.com
+
+ Koin Wrapped- Recapping the 2023 Milestones of Our Kotlin Integration Framework
+ In this post, the Koin crew wraps up all the milestones and roadmap achieved in 2023.
+ blog.cloud-inject.io +
+
+
+
+


+
+ Articles +

+
+

+ How to Use the Cucumber Framework to Test Application Use Cases
+ Matthias Schenk writes today about Cucumber, a framework that can be used in application development to verify the correct behavior of the application.
+ towardsdev.com
+
+ Jetpack Preferences DataStore in Kotlin Multiplatform (KMP)
+ FunkyMuse put up an article showcasing how to read and write preferences on multiple platforms when using the KMP DataStore library.
+ funkymuse.dev +
+
+
+
+


+
+ Android +

+
+

+ Using launcher and themed icons in Android Studio, the manual way
+ Marlon Lòpez describes how we can use the launcher and the themed icons in Android Studio.
+ dev.to +
+
+
+
+


+
+ Videos +

+
+

+ Setting Sail with Compose Multiplatform by Isuru Rajapakse
+ Isuru Rajapakse talks at the DevFest Sri Lanka about Compose Multiplatform.
+ www.youtube.com
+
+ Developer Experience and Kotlin Lenses
+ In his next video, Duncan McGregor keeps talking about Developer Experience and Kotlin lenses.
+ www.youtube.com
+
+ Network-Resilient Applications with Store5 | Talking Kotlin
+ In this chapter of Talking Kotlin, Mike Nakhimovich, Yigit Boyar, and Matthew Ramotar talk about Store, a Kotlin Multiplatform library for building network-resilient applications.
+ www.youtube.com +
+
+
+
+


+
+ Libraries +

+
+

+ Kim - Kotlin Image Metadata
+ Kim is a Kotlin image metadata manipulation library for Kotlin Multiplatform.
+ github.com
+
+ DiKTat
+ DiKTat is a strict coding standard for Kotlin, consisting of a collection of Kotlin code style rules implemented as Abstract Syntax Tree (AST) visitors built on top of KTlint.
+ github.com
+
+ FailGood
+ Failgood is a test runner for Kotlin focusing on simplicity, usability and speed.
+ github.com
+
+ exif-viewer
+ Free online EXIF Viewer built with Kotlin/WASM.
+ github.com +
+
+

Contribute

+

We rely on sponsors to offer quality content every Sunday. If you would like to submit a sponsored link contact us.

+

If you want to submit an article for the next issue, please do also drop us an email.
+   +

+


+ Thanks to JetBrains for their support!
+

+
+ + +
+
+ + + + + + +
+ + + + + + +
+ +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + + + + + + + +
+ + + + + + +
+ + + + + + +
+ Twitter +
+
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + +
+ Facebook +
+
+
+ + + + + + + + +
+ + + + + + +
+ + + + + + +
+ Website +
+
+
+ + +
+
+
+
+ + + + + + +
+ + + + + + + + +
+ Copyright © 2023 Kotlin Weekly, All rights reserved.
+
+
+ Want to change how you receive these emails?
+ You can update your preferences or unsubscribe from this list
+
+ Email Marketing Powered by Mailchimp +
+ + +
+ + + + + + +
+ + + + + + +
+ +
+ +
+
+ + +
+
+ + +