Skip to content

Commit 6e45fdb

Browse files
authored
Add MySQL Spring Boot autoconfiguration (KOJAK-37) (#3)
## Summary - Add `MysqlStoreConfiguration` to `OutboxAutoConfiguration` — zero-config `MysqlOutboxStore` + Liquibase migration when `okapi-mysql` is on the classpath - Fix `proxyBeanMethods = false` on `PostgresStoreConfiguration` (Kotlin final classes + CGLIB proxy) - Rename Postgres Liquibase bean to `okapiPostgresLiquibase` to avoid collision when both modules coexist When both `okapi-postgres` and `okapi-mysql` are on the classpath, Postgres takes priority (declaration order + `@ConditionalOnMissingBean`). Users can override by defining their own `@Bean OutboxStore`. ## Test plan - [x] MySQL E2E test: publish → claim → deliver → status update via Testcontainers MySQL 8.0 - [x] Existing Postgres E2E test still passes (no regressions) - [x] Full test suite: 23/23 pass in okapi-spring-boot, all other modules green - [x] ktlint check passes
1 parent a222730 commit 6e45fdb

4 files changed

Lines changed: 234 additions & 35 deletions

File tree

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,7 @@ bin/
4444
### Claude Code ###
4545
.claude/
4646
.ai/
47-
CLAUDE.md
47+
CLAUDE.md
48+
docs/specs/
49+
docs/plans/
50+
docs/superpowers/

okapi-spring-boot/build.gradle.kts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,11 @@ plugins {
55
dependencies {
66
implementation(project(":okapi-core"))
77

8-
// Spring provided by the consuming application — compileOnly keeps this module free of version lock-in
98
compileOnly(libs.springContext)
109
compileOnly(libs.springTx)
11-
12-
// Spring Boot autoconfiguration support — compileOnly so we don't force Spring Boot on consumers
1310
compileOnly(libs.springBootAutoconfigure)
14-
15-
// Optional postgres store autoconfiguration — compileOnly + @ConditionalOnClass guards runtime absence
1611
compileOnly(project(":okapi-postgres"))
17-
18-
// Optional Liquibase migration support — compileOnly, activated only when liquibase-core is on the runtime classpath
12+
compileOnly(project(":okapi-mysql"))
1913
compileOnly(libs.liquibaseCore)
2014

2115
testImplementation(libs.kotestRunnerJunit5)
@@ -25,15 +19,16 @@ dependencies {
2519
testImplementation(libs.exposedCore)
2620
testImplementation(libs.springBootAutoconfigure)
2721
testImplementation(project(":okapi-postgres"))
28-
29-
// E2E test dependencies
22+
testImplementation(project(":okapi-mysql"))
3023
testImplementation(project(":okapi-http"))
3124
testImplementation(libs.exposedJdbc)
3225
testImplementation(libs.exposedJson)
3326
testImplementation(libs.exposedJavaTime)
3427
testImplementation(libs.liquibaseCore)
3528
testImplementation(libs.testcontainersPostgresql)
3629
testImplementation(libs.postgresql)
30+
testImplementation(libs.testcontainersMysql)
31+
testImplementation(libs.mysql)
3732
testImplementation(libs.wiremock)
3833
}
3934

okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OutboxAutoConfiguration.kt

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import com.softwaremill.okapi.core.OutboxProcessor
77
import com.softwaremill.okapi.core.OutboxPublisher
88
import com.softwaremill.okapi.core.OutboxStore
99
import com.softwaremill.okapi.core.RetryPolicy
10+
import com.softwaremill.okapi.mysql.MysqlOutboxStore
1011
import com.softwaremill.okapi.postgres.PostgresOutboxStore
1112
import liquibase.integration.spring.SpringLiquibase
1213
import org.springframework.beans.factory.ObjectProvider
@@ -32,7 +33,9 @@ import javax.sql.DataSource
3233
* and routed by the `type` field in each entry's deliveryMetadata.
3334
*
3435
* Optional beans with defaults:
35-
* - [OutboxStore] — auto-configured to [PostgresOutboxStore] if `outbox-postgres` is on the classpath
36+
* - [OutboxStore] — auto-configured to [PostgresOutboxStore] or [MysqlOutboxStore]
37+
* depending on which module (`okapi-postgres` / `okapi-mysql`) is on the classpath.
38+
* If both are present, Postgres takes priority. Override by defining your own `@Bean OutboxStore`.
3639
* - [Clock] — defaults to [Clock.systemUTC]
3740
* - [RetryPolicy] — defaults to `maxRetries = 5`
3841
* - [PlatformTransactionManager] — if absent, each store call runs in its own transaction
@@ -97,38 +100,40 @@ class OutboxAutoConfiguration {
97100
)
98101
}
99102

100-
/**
101-
* Auto-configures [PostgresOutboxStore] and Liquibase schema migration
102-
* when `outbox-postgres` is on the classpath.
103-
* Skipped if the application provides its own [OutboxStore] bean.
104-
*/
105-
@Configuration
103+
@Configuration(proxyBeanMethods = false)
106104
@ConditionalOnClass(PostgresOutboxStore::class)
107105
class PostgresStoreConfiguration {
108106
@Bean
109107
@ConditionalOnMissingBean(OutboxStore::class)
110-
fun outboxStore(clock: ObjectProvider<Clock>): OutboxStore = PostgresOutboxStore(clock = clock.getIfAvailable { Clock.systemUTC() })
108+
fun outboxStore(clock: ObjectProvider<Clock>): PostgresOutboxStore =
109+
PostgresOutboxStore(clock = clock.getIfAvailable { Clock.systemUTC() })
111110

112-
/**
113-
* Runs okapi Liquibase migrations automatically when both:
114-
* - `liquibase-core` is on the classpath (i.e. the application already uses Liquibase)
115-
* - a [DataSource] bean is available
116-
*
117-
* The migration is idempotent (CREATE TABLE IF NOT EXISTS) and uses a separate
118-
* Liquibase bean named `okapiLiquibase` to avoid conflicting with the application's
119-
* own Liquibase configuration.
120-
*
121-
* To opt out, define your own bean named `okapiLiquibase` or include the okapi
122-
* changelog manually in your master changelog:
123-
* `classpath:com/softwaremill/okapi/db/changelog.xml`
124-
*/
125-
@Bean("okapiLiquibase")
111+
@Bean("okapiPostgresLiquibase")
126112
@ConditionalOnClass(SpringLiquibase::class)
127-
@ConditionalOnBean(DataSource::class)
128-
@ConditionalOnMissingBean(name = ["okapiLiquibase"])
129-
fun okapiLiquibase(dataSource: DataSource): SpringLiquibase = SpringLiquibase().apply {
113+
@ConditionalOnBean(value = [DataSource::class, PostgresOutboxStore::class])
114+
@ConditionalOnMissingBean(name = ["okapiPostgresLiquibase"])
115+
fun okapiPostgresLiquibase(dataSource: DataSource): SpringLiquibase = SpringLiquibase().apply {
130116
this.dataSource = dataSource
131117
changeLog = "classpath:com/softwaremill/okapi/db/changelog.xml"
132118
}
133119
}
120+
121+
/** When both Postgres and MySQL modules are on the classpath, [PostgresStoreConfiguration] takes priority. */
122+
@Configuration(proxyBeanMethods = false)
123+
@ConditionalOnClass(MysqlOutboxStore::class)
124+
class MysqlStoreConfiguration {
125+
@Bean
126+
@ConditionalOnMissingBean(OutboxStore::class)
127+
fun outboxStore(clock: ObjectProvider<Clock>): MysqlOutboxStore =
128+
MysqlOutboxStore(clock = clock.getIfAvailable { Clock.systemUTC() })
129+
130+
@Bean("okapiMysqlLiquibase")
131+
@ConditionalOnClass(SpringLiquibase::class)
132+
@ConditionalOnBean(value = [DataSource::class, MysqlOutboxStore::class])
133+
@ConditionalOnMissingBean(name = ["okapiMysqlLiquibase"])
134+
fun okapiMysqlLiquibase(dataSource: DataSource): SpringLiquibase = SpringLiquibase().apply {
135+
this.dataSource = dataSource
136+
changeLog = "classpath:com/softwaremill/okapi/db/mysql/changelog.xml"
137+
}
138+
}
134139
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
package com.softwaremill.okapi.springboot
2+
3+
import com.github.tomakehurst.wiremock.WireMockServer
4+
import com.github.tomakehurst.wiremock.client.WireMock.aResponse
5+
import com.github.tomakehurst.wiremock.client.WireMock.post
6+
import com.github.tomakehurst.wiremock.client.WireMock.postRequestedFor
7+
import com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo
8+
import com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig
9+
import com.softwaremill.okapi.core.OutboxEntryProcessor
10+
import com.softwaremill.okapi.core.OutboxMessage
11+
import com.softwaremill.okapi.core.OutboxProcessor
12+
import com.softwaremill.okapi.core.OutboxPublisher
13+
import com.softwaremill.okapi.core.OutboxStatus
14+
import com.softwaremill.okapi.core.RetryPolicy
15+
import com.softwaremill.okapi.http.HttpMessageDeliverer
16+
import com.softwaremill.okapi.http.ServiceUrlResolver
17+
import com.softwaremill.okapi.http.httpDeliveryInfo
18+
import com.softwaremill.okapi.mysql.MysqlOutboxStore
19+
import io.kotest.core.spec.style.BehaviorSpec
20+
import io.kotest.matchers.shouldBe
21+
import liquibase.Liquibase
22+
import liquibase.database.DatabaseFactory
23+
import liquibase.database.jvm.JdbcConnection
24+
import liquibase.resource.ClassLoaderResourceAccessor
25+
import org.jetbrains.exposed.v1.jdbc.Database
26+
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
27+
import org.testcontainers.containers.MySQLContainer
28+
import java.sql.DriverManager
29+
import java.time.Clock
30+
31+
class OutboxMysqlEndToEndTest :
32+
BehaviorSpec({
33+
val mysql = MySQLContainer<Nothing>("mysql:8.0")
34+
val wiremock = WireMockServer(wireMockConfig().dynamicPort())
35+
36+
lateinit var store: MysqlOutboxStore
37+
lateinit var publisher: OutboxPublisher
38+
lateinit var processor: OutboxProcessor
39+
40+
beforeSpec {
41+
mysql.start()
42+
wiremock.start()
43+
44+
Database.connect(
45+
url = mysql.jdbcUrl,
46+
driver = mysql.driverClassName,
47+
user = mysql.username,
48+
password = mysql.password,
49+
)
50+
51+
val connection = DriverManager.getConnection(mysql.jdbcUrl, mysql.username, mysql.password)
52+
val db = DatabaseFactory.getInstance().findCorrectDatabaseImplementation(JdbcConnection(connection))
53+
Liquibase("com/softwaremill/okapi/db/mysql/changelog.xml", ClassLoaderResourceAccessor(), db)
54+
.use { it.update("") }
55+
connection.close()
56+
57+
val clock = Clock.systemUTC()
58+
store = MysqlOutboxStore(clock)
59+
publisher = OutboxPublisher(store, clock)
60+
61+
val urlResolver = ServiceUrlResolver { "http://localhost:${wiremock.port()}" }
62+
val deliverer = HttpMessageDeliverer(urlResolver)
63+
val entryProcessor = OutboxEntryProcessor(deliverer, RetryPolicy(maxRetries = 3), clock)
64+
processor = OutboxProcessor(store, entryProcessor)
65+
}
66+
67+
afterSpec {
68+
wiremock.stop()
69+
mysql.stop()
70+
}
71+
72+
beforeEach {
73+
wiremock.resetAll()
74+
transaction { exec("DELETE FROM outbox") }
75+
}
76+
77+
given("a message published within a transaction") {
78+
`when`("the HTTP endpoint returns 200") {
79+
wiremock.stubFor(
80+
post(urlEqualTo("/api/notify"))
81+
.willReturn(aResponse().withStatus(200)),
82+
)
83+
84+
transaction {
85+
publisher.publish(
86+
OutboxMessage("order.created", """{"orderId":"abc-123"}"""),
87+
httpDeliveryInfo {
88+
serviceName = "notification-service"
89+
endpointPath = "/api/notify"
90+
},
91+
)
92+
}
93+
94+
transaction { processor.processNext() }
95+
96+
val requests = wiremock.findAll(postRequestedFor(urlEqualTo("/api/notify")))
97+
val counts = transaction { store.countByStatuses() }
98+
99+
then("WireMock receives exactly one POST request") {
100+
requests.size shouldBe 1
101+
}
102+
then("request body matches the published payload") {
103+
requests.first().bodyAsString shouldBe """{"orderId":"abc-123"}"""
104+
}
105+
then("entry is marked as DELIVERED") {
106+
counts[OutboxStatus.DELIVERED] shouldBe 1L
107+
}
108+
}
109+
110+
`when`("the HTTP endpoint returns 500") {
111+
wiremock.stubFor(
112+
post(urlEqualTo("/api/notify"))
113+
.willReturn(aResponse().withStatus(500).withBody("Internal Server Error")),
114+
)
115+
116+
transaction {
117+
publisher.publish(
118+
OutboxMessage("order.created", """{"orderId":"xyz-456"}"""),
119+
httpDeliveryInfo {
120+
serviceName = "notification-service"
121+
endpointPath = "/api/notify"
122+
},
123+
)
124+
}
125+
126+
transaction { processor.processNext() }
127+
128+
val counts = transaction { store.countByStatuses() }
129+
130+
then("entry stays PENDING (retriable failure, retries remaining)") {
131+
counts[OutboxStatus.PENDING] shouldBe 1L
132+
}
133+
then("no DELIVERED entries") {
134+
counts[OutboxStatus.DELIVERED] shouldBe 0L
135+
}
136+
}
137+
138+
`when`("the HTTP endpoint returns 400") {
139+
wiremock.stubFor(
140+
post(urlEqualTo("/api/notify"))
141+
.willReturn(aResponse().withStatus(400).withBody("Bad Request")),
142+
)
143+
144+
transaction {
145+
publisher.publish(
146+
OutboxMessage("order.created", """{"orderId":"err-789"}"""),
147+
httpDeliveryInfo {
148+
serviceName = "notification-service"
149+
endpointPath = "/api/notify"
150+
},
151+
)
152+
}
153+
154+
transaction { processor.processNext() }
155+
156+
val counts = transaction { store.countByStatuses() }
157+
158+
then("entry is immediately FAILED (permanent failure)") {
159+
counts[OutboxStatus.FAILED] shouldBe 1L
160+
}
161+
then("no PENDING or DELIVERED entries") {
162+
counts[OutboxStatus.PENDING] shouldBe 0L
163+
counts[OutboxStatus.DELIVERED] shouldBe 0L
164+
}
165+
}
166+
167+
`when`("the endpoint is unreachable") {
168+
wiremock.stubFor(
169+
post(urlEqualTo("/api/notify"))
170+
.willReturn(
171+
aResponse().withFault(
172+
com.github.tomakehurst.wiremock.http.Fault.CONNECTION_RESET_BY_PEER,
173+
),
174+
),
175+
)
176+
177+
transaction {
178+
publisher.publish(
179+
OutboxMessage("order.created", """{"orderId":"net-000"}"""),
180+
httpDeliveryInfo {
181+
serviceName = "notification-service"
182+
endpointPath = "/api/notify"
183+
},
184+
)
185+
}
186+
187+
transaction { processor.processNext() }
188+
189+
val counts = transaction { store.countByStatuses() }
190+
191+
then("entry stays PENDING (retriable network failure)") {
192+
counts[OutboxStatus.PENDING] shouldBe 1L
193+
}
194+
}
195+
}
196+
})

0 commit comments

Comments
 (0)