Skip to content

Commit d218fa0

Browse files
committed
Merge remote-tracking branch 'origin/main' into springboot-4
# Conflicts: # spring/build.gradle.kts # util/src/main/kotlin/com/caplin/integration/datasourcex/util/SimpleDataSourceFactory.kt
2 parents a179720 + 251fcb2 commit d218fa0

6 files changed

Lines changed: 456 additions & 33 deletions

File tree

MIGRATION.md

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,7 @@ change is required — plain POJOs serialize as before.
3030

3131
If you registered **custom Jackson 2 modules, serializers, or `ObjectMapper` customizers**, port them
3232
to Jackson 3. See the
33-
[Jackson 3 release notes](https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.0); the main
34-
changes are:
35-
36-
- group/package `com.fasterxml.jackson.*``tools.jackson.*` (annotations stay on
37-
`com.fasterxml.jackson`),
38-
- `JsonSerializer` / `JsonDeserializer``ValueSerializer` / `ValueDeserializer`,
39-
`SerializerProvider``SerializationContext`, `JsonMappingException``DatabindException`,
40-
- the `ObjectMapper` is immutable — build it via `JsonMapper.builder()…build()`.
33+
[Jackson 3 release notes](https://github.com/FasterXML/jackson/wiki/Jackson-Release-3.0).
4134

4235
### 3. Keeping Jackson 2 (optional)
4336

@@ -65,8 +58,3 @@ Outside Spring, `SimpleDataSourceFactory.createDataSource(...)` now defaults to
6558
handler; pass `SimpleDataSourceFactory.defaultJackson2JsonHandler` (or your own) explicitly to keep
6659
Jackson 2. On the `3.x` line the Jackson 2 artifacts are `compileOnly` in `datasourcex-util`, so add
6760
them to your own classpath if you use the Jackson 2 helpers directly.
68-
69-
### 4. Versioning note
70-
71-
The published `.api` surface is unchanged between `2.x` and `3.x`; the breaking change is the Spring
72-
Boot 4 / Jackson 3 runtime baseline, which is why this is a major version bump.

spring/build.gradle.kts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ dependencies {
2727
testImplementation(libs.kotest.assertions)
2828
testImplementation(libs.kotest.runner)
2929
testImplementation(libs.turbine)
30-
// Spring-context test of the autoconfiguration (kotest SpringExtension + @SpringBootTest).
31-
testImplementation("org.springframework.boot:spring-boot-test")
30+
// End-to-end SpringExtension test of the autoconfiguration + publish path.
3231
testImplementation(libs.kotest.extensions.spring)
32+
testImplementation("org.springframework.boot:spring-boot-test")
3333
}
3434

3535
val prepareReadme =
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package com.caplin.integration.datasourcex.spring.internal
2+
3+
import com.caplin.datasource.DataSource
4+
import com.caplin.integration.datasourcex.reactive.api.ContainerEvent
5+
import com.caplin.integration.datasourcex.reactive.api.ContainerEvent.RowEvent.Upsert
6+
import com.caplin.integration.datasourcex.spring.annotations.DataMessageMapping
7+
import com.caplin.integration.datasourcex.spring.annotations.DataMessageMapping.Type.MAPPING
8+
import com.caplin.integration.datasourcex.spring.annotations.DataMessageMapping.Type.RECORD_GENERIC
9+
import com.caplin.integration.datasourcex.spring.annotations.DataService
10+
import com.caplin.integration.datasourcex.spring.annotations.IngressDestinationVariable
11+
import com.caplin.integration.datasourcex.spring.annotations.IngressToken.USER_ID
12+
import io.kotest.assertions.nondeterministic.eventually
13+
import io.kotest.core.spec.style.FunSpec
14+
import io.kotest.extensions.spring.SpringExtension
15+
import io.kotest.matchers.collections.shouldContain
16+
import io.kotest.matchers.shouldBe
17+
import kotlin.time.Duration.Companion.seconds
18+
import kotlinx.coroutines.awaitCancellation
19+
import kotlinx.coroutines.flow.Flow
20+
import kotlinx.coroutines.flow.flow
21+
import kotlinx.coroutines.flow.flowOf
22+
import kotlinx.coroutines.flow.map
23+
import org.springframework.beans.factory.annotation.Autowired
24+
import org.springframework.boot.autoconfigure.ImportAutoConfiguration
25+
import org.springframework.boot.test.context.SpringBootTest
26+
import org.springframework.context.annotation.Bean
27+
import org.springframework.context.annotation.Configuration
28+
import org.springframework.messaging.handler.annotation.Payload
29+
import org.springframework.test.context.TestPropertySource
30+
import tools.jackson.databind.ObjectMapper
31+
import tools.jackson.module.kotlin.jacksonMapperBuilder
32+
33+
/**
34+
* End-to-end test of the Spring Boot starter: a real application context wires the
35+
* autoconfiguration, which binds the `@DataService` controller's `@DataMessageMapping` methods onto
36+
* a (faked) DataSource. Simulating peer requests drives the whole stack — message handler, argument
37+
* resolution, the controller, and the return-value handler — across every supported subject shape.
38+
*/
39+
@SpringBootTest(
40+
classes =
41+
[DataSourceEndToEndTest.TestConfig::class, DataSourceEndToEndTest.TestController::class]
42+
)
43+
@ImportAutoConfiguration(
44+
DataSourceAutoConfiguration::class,
45+
DataSourceServerAutoConfiguration::class,
46+
)
47+
@TestPropertySource(properties = ["caplin.datasource.managed.discovery.hostname=localhost"])
48+
class DataSourceEndToEndTest : FunSpec() {
49+
50+
@Autowired private lateinit var fake: FakeDataSource
51+
52+
init {
53+
extension(SpringExtension())
54+
55+
test("active JSON subject publishes the controller's flow") {
56+
fake.request("/price/USD")
57+
eventually(TIMEOUT) {
58+
fake.publishedJson shouldContain ("/price/USD" to mapOf("ccy" to "USD", "bid" to "1.23"))
59+
}
60+
}
61+
62+
test("active generic-record subject publishes the controller's flow as record fields") {
63+
fake.request("/record/EUR")
64+
eventually(TIMEOUT) {
65+
fake.publishedRecords shouldContain
66+
("/record/EUR" to mapOf("ccy" to "EUR", "bid" to "1.10"))
67+
}
68+
}
69+
70+
test("active mapping subject publishes the remapped subject") {
71+
fake.request("/map/abc")
72+
eventually(TIMEOUT) { fake.publishedMappings shouldContain ("/map/abc" to "/real/abc") }
73+
}
74+
75+
test("a streaming subject publishes each update in order then NotFound on completion") {
76+
fake.request("/ticks")
77+
eventually(TIMEOUT) {
78+
fake.publishedJson.filter { it.first == "/ticks" }.map { it.second } shouldBe
79+
listOf(mapOf("seq" to "1"), mapOf("seq" to "2"), mapOf("seq" to "3"))
80+
fake.erroredSubjects shouldContain "/ticks"
81+
}
82+
}
83+
84+
test("a failing flow publishes a SubjectError and nothing else") {
85+
fake.request("/broken")
86+
eventually(TIMEOUT) { fake.erroredSubjects shouldContain "/broken" }
87+
fake.publishedJson.any { it.first == "/broken" } shouldBe false
88+
}
89+
90+
test("a container subject publishes element structure and serves row data on request") {
91+
fake.request("/book/X")
92+
eventually(TIMEOUT) {
93+
fake.publishedContainers shouldContain ("/book/X" to listOf("/book/X-items/1"))
94+
}
95+
96+
fake.request("/book/X-items/1")
97+
eventually(TIMEOUT) {
98+
fake.publishedRecords shouldContain ("/book/X-items/1" to mapOf("px" to "100"))
99+
}
100+
}
101+
102+
test("a request-stream channel returns a response derived from the client's message") {
103+
val responses = fake.openChannel("/echo/42")
104+
fake.sendToChannel("/echo/42", mapOf("ping" to "1"))
105+
eventually(TIMEOUT) { responses shouldContain mapOf("ping" to "1", "echoedBy" to "42") }
106+
}
107+
108+
test("a bidirectional channel responds to each client message") {
109+
val responses = fake.openChannel("/chat/7")
110+
fake.sendToChannel("/chat/7", mapOf("m" to "a"))
111+
fake.sendToChannel("/chat/7", mapOf("m" to "b"))
112+
eventually(TIMEOUT) {
113+
responses shouldContain mapOf("m" to "a", "by" to "7")
114+
responses shouldContain mapOf("m" to "b", "by" to "7")
115+
}
116+
}
117+
118+
test("a fire-and-forget channel returns an OK acknowledgement") {
119+
val responses = fake.openChannel("/submit")
120+
fake.sendToChannel("/submit", mapOf("x" to "1"))
121+
eventually(TIMEOUT) { responses shouldContain mapOf("Result" to "ok") }
122+
}
123+
}
124+
125+
@DataService
126+
class TestController {
127+
@DataMessageMapping("/price/{ccy}")
128+
fun price(@IngressDestinationVariable(token = USER_ID, value = "ccy") ccy: String): Flow<Any> =
129+
flowOf(mapOf("ccy" to ccy, "bid" to "1.23"))
130+
131+
@DataMessageMapping("/record/{ccy}", type = RECORD_GENERIC)
132+
fun record(
133+
@IngressDestinationVariable(token = USER_ID, value = "ccy") ccy: String
134+
): Flow<Map<String, String>> = flowOf(mapOf("ccy" to ccy, "bid" to "1.10"))
135+
136+
@DataMessageMapping("/map/{id}", type = MAPPING)
137+
fun map(@IngressDestinationVariable(token = USER_ID, value = "id") id: String): Flow<String> =
138+
flowOf("/real/$id")
139+
140+
@DataMessageMapping("/ticks")
141+
fun ticks(): Flow<Any> = flowOf(mapOf("seq" to "1"), mapOf("seq" to "2"), mapOf("seq" to "3"))
142+
143+
@DataMessageMapping("/broken") fun broken(): Flow<Any> = flow { error("boom") }
144+
145+
@DataMessageMapping("/book/{id}", type = RECORD_GENERIC)
146+
fun book(
147+
@IngressDestinationVariable(token = USER_ID, value = "id") id: String
148+
): Flow<ContainerEvent<Map<String, String>>> = flow {
149+
emit(Upsert("1", mapOf("px" to "100")))
150+
awaitCancellation()
151+
}
152+
153+
@DataMessageMapping("/echo/{id}")
154+
fun echo(
155+
@IngressDestinationVariable(token = USER_ID, value = "id") id: String,
156+
@Payload request: Map<String, String>,
157+
): Flow<Map<String, String>> = flowOf(request + ("echoedBy" to id))
158+
159+
@DataMessageMapping("/chat/{id}")
160+
fun chat(
161+
@IngressDestinationVariable(token = USER_ID, value = "id") id: String,
162+
@Payload messages: Flow<Map<String, String>>,
163+
): Flow<Map<String, String>> = messages.map { it + ("by" to id) }
164+
165+
@DataMessageMapping("/submit")
166+
fun submit(@Payload request: Map<String, String>) {
167+
// fire-and-forget: side effects only; the starter returns an OK acknowledgement
168+
}
169+
}
170+
171+
@Configuration(proxyBeanMethods = false)
172+
class TestConfig {
173+
private val fake = FakeDataSource()
174+
175+
@Bean fun fakeDataSource(): FakeDataSource = fake
176+
177+
@Bean fun dataSource(): DataSource = fake.dataSource
178+
179+
@Bean fun objectMapper(): ObjectMapper = jacksonMapperBuilder().build()
180+
}
181+
182+
private companion object {
183+
val TIMEOUT = 5.seconds
184+
}
185+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package com.caplin.integration.datasourcex.spring.internal
2+
3+
import com.caplin.integration.datasourcex.spring.internal.DataSourcePayloadReturnValueHandler.Companion.RESPONSE_HEADER
4+
import io.kotest.assertions.throwables.shouldThrow
5+
import io.kotest.core.spec.style.FunSpec
6+
import io.kotest.matchers.shouldBe
7+
import java.util.concurrent.atomic.AtomicReference
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.flowOf
10+
import kotlinx.coroutines.flow.toList
11+
import kotlinx.coroutines.runBlocking
12+
import org.springframework.core.MethodParameter
13+
import org.springframework.core.ReactiveAdapterRegistry
14+
import org.springframework.messaging.support.MessageBuilder
15+
16+
internal class DataSourcePayloadReturnValueHandlerTest :
17+
FunSpec({
18+
val handler = DataSourcePayloadReturnValueHandler(ReactiveAdapterRegistry.getSharedInstance())
19+
20+
fun returnType(methodName: String) =
21+
MethodParameter(ReturnValues::class.java.getMethod(methodName), -1)
22+
23+
fun message(ref: AtomicReference<Flow<Any>>?) =
24+
MessageBuilder.withPayload("ignored")
25+
.apply { ref?.let { setHeader(RESPONSE_HEADER, it) } }
26+
.build()
27+
28+
fun collect(ref: AtomicReference<Flow<Any>>) = runBlocking { ref.get().toList() }
29+
30+
test("a single value is exposed as a one-element flow") {
31+
val ref = AtomicReference<Flow<Any>>()
32+
handler.handleReturnValue("hello", returnType("value"), message(ref)).block()
33+
collect(ref) shouldBe listOf("hello")
34+
}
35+
36+
test("a null return value is exposed as an empty flow") {
37+
val ref = AtomicReference<Flow<Any>>()
38+
handler.handleReturnValue(null, returnType("value"), message(ref)).block()
39+
collect(ref) shouldBe emptyList()
40+
}
41+
42+
test("a returned Flow is exposed element by element") {
43+
val ref = AtomicReference<Flow<Any>>()
44+
handler.handleReturnValue(flowOf("a", "b", "c"), returnType("flow"), message(ref)).block()
45+
collect(ref) shouldBe listOf("a", "b", "c")
46+
}
47+
48+
test("a missing response-reference header fails") {
49+
shouldThrow<IllegalStateException> {
50+
handler.handleReturnValue("hello", returnType("value"), message(null)).block()
51+
}
52+
}
53+
})
54+
55+
private class ReturnValues {
56+
fun value(): Any = "ignored"
57+
58+
fun flow(): Flow<Any> = flowOf()
59+
}

0 commit comments

Comments
 (0)