mirror of
https://github.com/Dannecron/spring-boot-demo.git
synced 2025-12-26 00:32:34 +03:00
add neko integration
improve build.gradle.kts
This commit is contained in:
@@ -13,6 +13,10 @@ plugins {
|
|||||||
group = "com.github.dannecron.demo"
|
group = "com.github.dannecron.demo"
|
||||||
version = "single-version"
|
version = "single-version"
|
||||||
|
|
||||||
|
val ktorVersion: String by project
|
||||||
|
val springVersion: String by project
|
||||||
|
val testContainersVersion: String by project
|
||||||
|
|
||||||
java {
|
java {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
@@ -22,7 +26,7 @@ repositories {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
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.module:jackson-module-kotlin:2.15.4")
|
||||||
implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310: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.micrometer:micrometer-tracing-bridge-otel")
|
||||||
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
|
implementation("io.opentelemetry:opentelemetry-exporter-otlp")
|
||||||
implementation("net.logstash.logback:logstash-logback-encoder:8.0")
|
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.flywaydb:flyway-core:9.22.3")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
|
implementation("org.jetbrains.kotlin:kotlin-reflect:2.0.20")
|
||||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
|
||||||
implementation("org.postgresql:postgresql:42.6.2")
|
implementation("org.postgresql:postgresql:42.6.2")
|
||||||
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
|
implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0")
|
||||||
implementation("org.springframework:spring-aspects")
|
implementation("org.springframework:spring-aspects")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator:3.2.10")
|
implementation("org.springframework.boot:spring-boot-starter-actuator:$springVersion")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-mustache:3.2.10")
|
implementation("org.springframework.boot:spring-boot-starter-mustache:$springVersion")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-validation:3.2.10")
|
implementation("org.springframework.boot:spring-boot-starter-validation:$springVersion")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-web:3.2.10")
|
implementation("org.springframework.boot:spring-boot-starter-web:$springVersion")
|
||||||
implementation("org.springframework.kafka:spring-kafka:3.1.3")
|
implementation("org.springframework.kafka:spring-kafka:3.1.3")
|
||||||
|
|
||||||
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
runtimeOnly("io.micrometer:micrometer-registry-prometheus")
|
||||||
@@ -48,11 +54,12 @@ dependencies {
|
|||||||
|
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:2.0.20")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5:2.0.20")
|
||||||
testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0")
|
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.springframework.kafka:spring-kafka-test:3.1.3")
|
||||||
testImplementation("org.testcontainers:junit-jupiter:1.19.7")
|
testImplementation("org.testcontainers:junit-jupiter:$testContainersVersion")
|
||||||
testImplementation("org.testcontainers:testcontainers:1.19.7")
|
testImplementation("org.testcontainers:testcontainers:$testContainersVersion")
|
||||||
testImplementation("org.testcontainers:postgresql:1.19.7")
|
testImplementation("org.testcontainers:postgresql:$testContainersVersion")
|
||||||
|
testImplementation("io.ktor:ktor-client-mock:$ktorVersion")
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
|
|||||||
3
gradle.properties
Normal file
3
gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
ktorVersion=3.0.0
|
||||||
|
springVersion=3.2.10
|
||||||
|
testContainersVersion=1.19.7
|
||||||
@@ -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.kafka.Producer
|
||||||
import com.github.dannecron.demo.services.validation.SchemaValidator
|
import com.github.dannecron.demo.services.validation.SchemaValidator
|
||||||
import com.github.dannecron.demo.services.validation.SchemaValidatorImp
|
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.ObservationRegistry
|
||||||
import io.micrometer.observation.aop.ObservedAspect
|
import io.micrometer.observation.aop.ObservedAspect
|
||||||
import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter
|
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.boot.context.properties.EnableConfigurationProperties
|
||||||
import org.springframework.context.annotation.Bean
|
import org.springframework.context.annotation.Bean
|
||||||
import org.springframework.context.annotation.Configuration
|
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
|
@Configuration
|
||||||
@EnableConfigurationProperties(KafkaProperties::class, ValidationProperties::class)
|
@EnableConfigurationProperties(KafkaProperties::class, ValidationProperties::class)
|
||||||
@@ -68,5 +72,17 @@ class AppConfig(
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
fun observedAspect(@Autowired observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry)
|
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<String>
|
||||||
|
|
||||||
|
@Throws(RequestException::class)
|
||||||
|
fun getImages(category: String, amount: Int): ImagesResponse
|
||||||
|
}
|
||||||
@@ -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<Map<String, CategoryFormat>>(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<ImagesResponse>(response.bodyAsText())
|
||||||
|
}
|
||||||
|
?: throw RequestException("get images error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.github.dannecron.demo.services.neko.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class CategoryFormat(
|
||||||
|
val format: String,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.github.dannecron.demo.services.neko.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ImagesResponse(
|
||||||
|
val results: List<Image>
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
package com.github.dannecron.demo.services.neko.exceptions
|
||||||
|
|
||||||
|
class RequestException(message: String): RuntimeException(message)
|
||||||
@@ -7,3 +7,8 @@ fun Double.roundTo(numFractionDigits: Int): Double {
|
|||||||
val factor = 10.0.pow(numFractionDigits.toDouble())
|
val factor = 10.0.pow(numFractionDigits.toDouble())
|
||||||
return (this * factor).roundToInt() / factor
|
return (this * factor).roundToInt() / factor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun String.snakeToCamelCase(): String {
|
||||||
|
val pattern = "_[a-z]".toRegex()
|
||||||
|
return replace(pattern) { it.value.last().uppercase() }
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ kafka:
|
|||||||
consumer:
|
consumer:
|
||||||
group-id: demo-consumer
|
group-id: demo-consumer
|
||||||
topics: demo-city-sync
|
topics: demo-city-sync
|
||||||
auto-offset-reset: none
|
auto-offset-reset: latest
|
||||||
auto-startup: true
|
auto-startup: true
|
||||||
|
|
||||||
validation:
|
validation:
|
||||||
@@ -67,3 +67,6 @@ management:
|
|||||||
|
|
||||||
tracing:
|
tracing:
|
||||||
url: ${OTLP_TRACING_HTTP_URL:http://localhost:4318/v1/traces}
|
url: ${OTLP_TRACING_HTTP_URL:http://localhost:4318/v1/traces}
|
||||||
|
|
||||||
|
neko:
|
||||||
|
baseUrl: https://nekos.best
|
||||||
|
|||||||
@@ -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<RequestException> {
|
||||||
|
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<RequestException> {
|
||||||
|
client.getImages(category = category, amount = amount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user