From 511116f8bbc9787a335984591c031748f57f186d Mon Sep 17 00:00:00 2001 From: Denis Savosin Date: Thu, 24 Oct 2024 12:49:44 +0700 Subject: [PATCH] add neko integration improve build.gradle.kts --- build.gradle.kts | 25 ++- gradle.properties | 3 + .../github/dannecron/demo/config/AppConfig.kt | 16 ++ .../dannecron/demo/services/neko/Client.kt | 14 ++ .../demo/services/neko/ClientImpl.kt | 46 ++++++ .../demo/services/neko/dto/CategoryFormat.kt | 8 + .../dannecron/demo/services/neko/dto/Image.kt | 17 ++ .../demo/services/neko/dto/ImagesResponse.kt | 8 + .../neko/exceptions/RequestException.kt | 3 + .../github/dannecron/demo/utils/Extensions.kt | 5 + src/main/resources/application.yml | 5 +- .../demo/services/neko/ClientImplTest.kt | 155 ++++++++++++++++++ 12 files changed, 295 insertions(+), 10 deletions(-) create mode 100644 gradle.properties create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/Client.kt create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/ClientImpl.kt create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/dto/CategoryFormat.kt create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/dto/Image.kt create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/dto/ImagesResponse.kt create mode 100644 src/main/kotlin/com/github/dannecron/demo/services/neko/exceptions/RequestException.kt create mode 100644 src/test/kotlin/com/github/dannecron/demo/services/neko/ClientImplTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index c62f37d..43bedc4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,10 @@ plugins { group = "com.github.dannecron.demo" version = "single-version" +val ktorVersion: String by project +val springVersion: String by project +val testContainersVersion: String by project + java { sourceCompatibility = JavaVersion.VERSION_17 } @@ -22,7 +26,7 @@ repositories { } dependencies { - api("org.springframework.boot:spring-boot-starter-data-jdbc:3.2.10") + api("org.springframework.boot:spring-boot-starter-data-jdbc:$springVersion") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.4") implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.15.4") @@ -30,16 +34,18 @@ dependencies { implementation("io.micrometer:micrometer-tracing-bridge-otel") implementation("io.opentelemetry:opentelemetry-exporter-otlp") implementation("net.logstash.logback:logstash-logback-encoder:8.0") + implementation("io.ktor:ktor-client-core:$ktorVersion") + implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("org.flywaydb:flyway-core:9.22.3") implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3") implementation("org.postgresql:postgresql:42.6.2") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0") implementation("org.springframework:spring-aspects") - implementation("org.springframework.boot:spring-boot-starter-actuator:3.2.10") - implementation("org.springframework.boot:spring-boot-starter-mustache:3.2.10") - implementation("org.springframework.boot:spring-boot-starter-validation:3.2.10") - implementation("org.springframework.boot:spring-boot-starter-web:3.2.10") + implementation("org.springframework.boot:spring-boot-starter-actuator:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-mustache:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-validation:$springVersion") + implementation("org.springframework.boot:spring-boot-starter-web:$springVersion") implementation("org.springframework.kafka:spring-kafka:3.1.3") runtimeOnly("io.micrometer:micrometer-registry-prometheus") @@ -48,11 +54,12 @@ dependencies { testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:2.0.20") testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") - testImplementation("org.springframework.boot:spring-boot-starter-test:3.2.10") + testImplementation("org.springframework.boot:spring-boot-starter-test:$springVersion") testImplementation("org.springframework.kafka:spring-kafka-test:3.1.3") - testImplementation("org.testcontainers:junit-jupiter:1.19.7") - testImplementation("org.testcontainers:testcontainers:1.19.7") - testImplementation("org.testcontainers:postgresql:1.19.7") + testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion") + testImplementation("org.testcontainers:testcontainers:$testContainersVersion") + testImplementation("org.testcontainers:postgresql:$testContainersVersion") + testImplementation("io.ktor:ktor-client-mock:$ktorVersion") } kotlin { diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..cf46779 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,3 @@ +ktorVersion=3.0.0 +springVersion=3.2.10 +testContainersVersion=1.19.7 diff --git a/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt b/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt index 0cfe468..be02ab6 100644 --- a/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt +++ b/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt @@ -17,6 +17,8 @@ import com.github.dannecron.demo.services.database.product.ProductServiceImpl import com.github.dannecron.demo.services.kafka.Producer import com.github.dannecron.demo.services.validation.SchemaValidator import com.github.dannecron.demo.services.validation.SchemaValidatorImp +import io.ktor.client.engine.* +import io.ktor.client.engine.cio.* import io.micrometer.observation.ObservationRegistry import io.micrometer.observation.aop.ObservedAspect import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter @@ -25,6 +27,8 @@ import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration +import com.github.dannecron.demo.services.neko.Client as NekoClient +import com.github.dannecron.demo.services.neko.ClientImpl as NekoClientImpl @Configuration @EnableConfigurationProperties(KafkaProperties::class, ValidationProperties::class) @@ -68,5 +72,17 @@ class AppConfig( @Bean fun observedAspect(@Autowired observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry) + + @Bean + fun httpClientEngine(): HttpClientEngine = CIO.create() + + @Bean + fun nekoClient( + @Autowired httpClientEngine: HttpClientEngine, + @Value("\${neko.baseUrl}") baseUrl: String, + ): NekoClient = NekoClientImpl( + engine = httpClientEngine, + baseUrl = baseUrl, + ) } diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/Client.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/Client.kt new file mode 100644 index 0000000..ceb1438 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/Client.kt @@ -0,0 +1,14 @@ +package com.github.dannecron.demo.services.neko + +import com.github.dannecron.demo.services.neko.dto.ImagesResponse +import com.github.dannecron.demo.services.neko.exceptions.RequestException +import org.springframework.stereotype.Service + +@Service +interface Client { + @Throws(RequestException::class) + fun getCategories(): Set + + @Throws(RequestException::class) + fun getImages(category: String, amount: Int): ImagesResponse +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/ClientImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/ClientImpl.kt new file mode 100644 index 0000000..9d0b75e --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/ClientImpl.kt @@ -0,0 +1,46 @@ +package com.github.dannecron.demo.services.neko + +import com.github.dannecron.demo.services.neko.dto.CategoryFormat +import com.github.dannecron.demo.services.neko.dto.ImagesResponse +import com.github.dannecron.demo.services.neko.exceptions.RequestException +import io.ktor.client.* +import io.ktor.client.engine.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json + +class ClientImpl( + engine: HttpClientEngine, + private val baseUrl: String, +): Client { + private val httpClient = HttpClient(engine) + + override fun getCategories() = runBlocking { + httpClient.get(urlString = baseUrl) { + url { + path("/api/v2/endpoints") + } + } + .takeIf { it.status.value in 200..209 } + ?.let { + response -> Json.decodeFromString>(response.bodyAsText()).keys + } + ?: throw RequestException("get categories error") + } + + override fun getImages(category: String, amount: Int) = runBlocking { + httpClient.get(urlString = baseUrl) { + url { + path("/api/v2/$category") + parameters.append("amount", amount.toString()) + } + } + .takeIf { it.status.value in 200..209 } + ?.let { + response -> Json.decodeFromString(response.bodyAsText()) + } + ?: throw RequestException("get images error") + } +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/CategoryFormat.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/CategoryFormat.kt new file mode 100644 index 0000000..875cd74 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/CategoryFormat.kt @@ -0,0 +1,8 @@ +package com.github.dannecron.demo.services.neko.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class CategoryFormat( + val format: String, +) diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/Image.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/Image.kt new file mode 100644 index 0000000..b345ba1 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/Image.kt @@ -0,0 +1,17 @@ +package com.github.dannecron.demo.services.neko.dto + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class Image( + val url: String, + @SerialName("anime_name") + val animeName: String? = null, + @SerialName("artist_href") + val artistHref: String? = null, + @SerialName("artist_name") + val artistName: String? = null, + @SerialName("source_url") + val sourceUrl: String? = null, +) diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/ImagesResponse.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/ImagesResponse.kt new file mode 100644 index 0000000..3a93403 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/dto/ImagesResponse.kt @@ -0,0 +1,8 @@ +package com.github.dannecron.demo.services.neko.dto + +import kotlinx.serialization.Serializable + +@Serializable +data class ImagesResponse( + val results: List +) diff --git a/src/main/kotlin/com/github/dannecron/demo/services/neko/exceptions/RequestException.kt b/src/main/kotlin/com/github/dannecron/demo/services/neko/exceptions/RequestException.kt new file mode 100644 index 0000000..707aa07 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/neko/exceptions/RequestException.kt @@ -0,0 +1,3 @@ +package com.github.dannecron.demo.services.neko.exceptions + +class RequestException(message: String): RuntimeException(message) diff --git a/src/main/kotlin/com/github/dannecron/demo/utils/Extensions.kt b/src/main/kotlin/com/github/dannecron/demo/utils/Extensions.kt index 2daf840..0022e8d 100644 --- a/src/main/kotlin/com/github/dannecron/demo/utils/Extensions.kt +++ b/src/main/kotlin/com/github/dannecron/demo/utils/Extensions.kt @@ -7,3 +7,8 @@ fun Double.roundTo(numFractionDigits: Int): Double { val factor = 10.0.pow(numFractionDigits.toDouble()) return (this * factor).roundToInt() / factor } + +fun String.snakeToCamelCase(): String { + val pattern = "_[a-z]".toRegex() + return replace(pattern) { it.value.last().uppercase() } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 783741c..63c2aea 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -29,7 +29,7 @@ kafka: consumer: group-id: demo-consumer topics: demo-city-sync - auto-offset-reset: none + auto-offset-reset: latest auto-startup: true validation: @@ -67,3 +67,6 @@ management: tracing: url: ${OTLP_TRACING_HTTP_URL:http://localhost:4318/v1/traces} + +neko: + baseUrl: https://nekos.best diff --git a/src/test/kotlin/com/github/dannecron/demo/services/neko/ClientImplTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/neko/ClientImplTest.kt new file mode 100644 index 0000000..79479c2 --- /dev/null +++ b/src/test/kotlin/com/github/dannecron/demo/services/neko/ClientImplTest.kt @@ -0,0 +1,155 @@ +package com.github.dannecron.demo.services.neko + +import com.github.dannecron.demo.BaseUnitTest +import com.github.dannecron.demo.services.neko.exceptions.RequestException +import io.ktor.client.engine.mock.* +import io.ktor.http.* +import io.ktor.utils.io.* +import org.junit.jupiter.api.assertThrows +import kotlin.test.* + +class ClientImplTest: BaseUnitTest() { + @Test + fun getCategories_success() { + val mockEngine = MockEngine { req -> + assertEquals("localhost", req.url.host) + assertEquals("/api/v2/endpoints", req.url.encodedPath) + + respond( + content = ByteReadChannel( + """ + {"neko": {"format": "png"},"wink": {"format": "gif"}} + """.trimIndent() + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = ClientImpl(engine = mockEngine, baseUrl = "https://localhost") + client.getCategories().let { + assertContains(it.toList(), "neko") + assertContains(it.toList(), "wink") + } + } + + @Test + fun getCategories_fail() { + val mockEngine = MockEngine { req -> + assertEquals("localhost", req.url.host) + assertEquals("/api/v2/endpoints", req.url.encodedPath) + + respond( + content = ByteReadChannel(""), + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "text/plain"), + ) + } + + val client = ClientImpl(engine = mockEngine, baseUrl = "https://localhost") + assertThrows { + client.getCategories() + } + } + + @Test + fun getImages_success_gifs() { + val category = "hug" + val amount = 2 + + val mockEngine = MockEngine { req -> + assertEquals("localhost", req.url.host) + assertEquals("/api/v2/$category", req.url.encodedPath) + assertTrue { + req.url.parameters.contains("amount", amount.toString()) + } + + respond( + content = ByteReadChannel( + """{"results":[ + {"anime_name":"Sword Art Online", + "url":"https://nekos.best/api/v2/hug/c6a7d384-dc40-11ed-afa1-0242ac120002.gif" + }, + {"anime_name":"Hibike! Euphonium", + "url":"https://nekos.best/api/v2/hug/ca26cfba-dc40-11ed-afa1-0242ac120002.gif" + } + ]}""".trimIndent(), + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = ClientImpl(engine = mockEngine, baseUrl = "https://localhost") + client.getImages(category = category, amount = amount).results.map { + assertNull(it.sourceUrl) + assertNull(it.artistName) + assertNull(it.artistHref) + assertNotNull(it.animeName) + assertContains(it.url, "https://nekos.best/api/v2/hug") + } + } + + @Test + fun getImages_success_jpegs() { + val category = "neko" + val amount = 1 + + val mockEngine = MockEngine { req -> + assertEquals("localhost", req.url.host) + assertEquals("/api/v2/$category", req.url.encodedPath) + assertTrue { + req.url.parameters.contains("amount", amount.toString()) + } + + respond( + content = ByteReadChannel( + """{"results":[ + { + "artist_href":"https://www.pixiv.net/en/users/47065875", + "artist_name":"かえで", + "source_url":"https://www.pixiv.net/en/artworks/88682108", + "url":"https://nekos.best/api/v2/neko/bbffa4e8-dc40-11ed-afa1-0242ac120002.png" + } + ]}""".trimIndent(), + ), + status = HttpStatusCode.OK, + headers = headersOf(HttpHeaders.ContentType, "application/json"), + ) + } + + val client = ClientImpl(engine = mockEngine, baseUrl = "https://localhost") + client.getImages(category = category, amount = amount).results.map { + assertNotNull(it.sourceUrl) + assertNotNull(it.artistName) + assertNotNull(it.artistHref) + assertNull(it.animeName) + assertContains(it.url, "https://nekos.best/api/v2/neko") + } + } + + @Test + fun getImages_fail() { + val category = "hug" + val amount = 2 + + val mockEngine = MockEngine { req -> + assertEquals("localhost", req.url.host) + assertEquals("/api/v2/$category", req.url.encodedPath) + assertTrue { + req.url.parameters.contains("amount", amount.toString()) + } + + respond( + content = ByteReadChannel(""), + status = HttpStatusCode.InternalServerError, + headers = headersOf(HttpHeaders.ContentType, "plain/text"), + ) + } + + val client = ClientImpl(engine = mockEngine, baseUrl = "https://localhost") + assertThrows { + client.getImages(category = category, amount = amount) + } + } +}