diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b8a707f..ed98b22 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -46,6 +46,7 @@ springJdbc = { module = "org.springframework:spring-jdbc", version.ref = "spring springBootAutoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "springBoot" } springBootStarterValidation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "springBoot" } springBootTest = { module = "org.springframework.boot:spring-boot-test", version.ref = "springBoot" } +springBootStarterActuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "springBoot" } assertjCore = { module = "org.assertj:assertj-core", version.ref = "assertj" } micrometerCore = { module = "io.micrometer:micrometer-core", version.ref = "micrometer" } micrometerTest = { module = "io.micrometer:micrometer-test", version.ref = "micrometer" } diff --git a/okapi-spring-boot/build.gradle.kts b/okapi-spring-boot/build.gradle.kts index 7404539..96ad04c 100644 --- a/okapi-spring-boot/build.gradle.kts +++ b/okapi-spring-boot/build.gradle.kts @@ -43,6 +43,8 @@ dependencies { testImplementation(libs.wiremock) testImplementation(project(":okapi-micrometer")) testImplementation(libs.micrometerCore) + // Brings in the metrics auto-config jar so @AutoConfigureAfter targets are resolvable in tests. + testImplementation(libs.springBootStarterActuator) } // CI version override: ./gradlew :okapi-spring-boot:test -PspringBootVersion=4.0.4 -PspringVersion=7.0.6 diff --git a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt index 42f0349..22d3b7e 100644 --- a/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt +++ b/okapi-spring-boot/src/main/kotlin/com/softwaremill/okapi/springboot/OkapiMicrometerAutoConfiguration.kt @@ -31,7 +31,12 @@ import java.time.Clock */ @AutoConfiguration @AutoConfigureAfter( - name = ["org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration"], + name = [ + // Spring Boot 3.5.x + "org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration", + // Spring Boot 4.0.x + "org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration", + ], ) @ConditionalOnClass(name = ["io.micrometer.core.instrument.MeterRegistry"]) @ConditionalOnBean(MeterRegistry::class) diff --git a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt index ec4f87a..38a60ac 100644 --- a/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt +++ b/okapi-spring-boot/src/test/kotlin/com/softwaremill/okapi/springboot/OutboxProcessorAutoConfigurationTest.kt @@ -8,10 +8,13 @@ import com.softwaremill.okapi.core.OutboxStore import com.softwaremill.okapi.micrometer.MicrometerOutboxListener import com.softwaremill.okapi.micrometer.MicrometerOutboxMetrics import com.softwaremill.okapi.micrometer.OutboxMetricsRefresher +import io.kotest.assertions.withClue import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldNotBeEmpty import io.kotest.matchers.nulls.shouldNotBeNull import io.kotest.matchers.shouldBe import org.springframework.boot.autoconfigure.AutoConfigurations +import org.springframework.boot.autoconfigure.AutoConfigureAfter import org.springframework.boot.test.context.runner.ApplicationContextRunner import org.springframework.jdbc.datasource.SimpleDriverDataSource import java.time.Duration.ofMillis @@ -90,7 +93,7 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ } } - test("listener, metrics and refresher are wired when MeterRegistry is present") { + test("listener, metrics and refresher are wired when a MeterRegistry bean is provided directly") { contextRunner .withBean(io.micrometer.core.instrument.MeterRegistry::class.java, { io.micrometer.core.instrument.simple.SimpleMeterRegistry() @@ -124,8 +127,87 @@ class OutboxProcessorAutoConfigurationTest : FunSpec({ props.refreshInterval shouldBe ofSeconds(15) } } + + // Exercises real Spring Boot metrics auto-config ordering: MeterRegistry is created by SimpleMetricsExportAutoConfiguration + // (not pre-registered as a user bean), so @AutoConfigureAfter on OkapiMicrometerAutoConfiguration must actually resolve + // and order correctly for the listener to be wired. + test("listener is wired under real Spring Boot metrics auto-config ordering") { + // Each pair lists the same auto-config in 3.5.x (`actuate.autoconfigure.metrics`) and 4.0.x (`micrometer.metrics.autoconfigure`) layouts. + val metricsAutoConfig = resolveSpringBootClass( + "org.springframework.boot.actuate.autoconfigure.metrics.MetricsAutoConfiguration", + "org.springframework.boot.micrometer.metrics.autoconfigure.MetricsAutoConfiguration", + ) + val compositeMeterRegistryAutoConfig = resolveSpringBootClass( + "org.springframework.boot.actuate.autoconfigure.metrics.CompositeMeterRegistryAutoConfiguration", + "org.springframework.boot.micrometer.metrics.autoconfigure.CompositeMeterRegistryAutoConfiguration", + ) + val simpleMetricsExportAutoConfig = resolveSpringBootClass( + "org.springframework.boot.actuate.autoconfigure.metrics.export.simple.SimpleMetricsExportAutoConfiguration", + "org.springframework.boot.micrometer.metrics.autoconfigure.export.simple.SimpleMetricsExportAutoConfiguration", + ) + + ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of( + OutboxAutoConfiguration::class.java, + OkapiMicrometerAutoConfiguration::class.java, + metricsAutoConfig, + compositeMeterRegistryAutoConfig, + simpleMetricsExportAutoConfig, + ), + ) + .withBean(OutboxStore::class.java, { stubStore() }) + .withBean(MessageDeliverer::class.java, { stubDeliverer() }) + .withBean(DataSource::class.java, { SimpleDriverDataSource() }) + .run { ctx -> + ctx.getBean(io.micrometer.core.instrument.MeterRegistry::class.java).shouldNotBeNull() + ctx.getBean(MicrometerOutboxListener::class.java).shouldNotBeNull() + ctx.getBean(MicrometerOutboxMetrics::class.java).shouldNotBeNull() + ctx.getBean(OutboxMetricsRefresher::class.java).shouldNotBeNull() + } + } + + // @AutoConfigureAfter(name = ...) silently drops entries whose class is missing — if none resolve, the ordering hint is a no-op. + test("AutoConfigureAfter on OkapiMicrometerAutoConfiguration resolves on the runtime classpath") { + val annotation = OkapiMicrometerAutoConfiguration::class.java.getAnnotation(AutoConfigureAfter::class.java) + annotation.shouldNotBeNull() + + val declaredNames = annotation.name.toList() + declaredNames.shouldNotBeEmpty() + + val classLoader = OkapiMicrometerAutoConfiguration::class.java.classLoader + val resolvable = declaredNames.filter { name -> + try { + Class.forName(name, false, classLoader) + true + } catch (_: ClassNotFoundException) { + false + } + } + + withClue( + "None of the @AutoConfigureAfter targets $declaredNames resolve on this Spring Boot runtime; " + + "the ordering hint is silently ignored and OkapiMicrometerAutoConfiguration may be evaluated " + + "before MeterRegistry is registered.", + ) { + resolvable.shouldNotBeEmpty() + } + } }) +// Loads a Spring Boot auto-config class by trying version-specific FQCNs in order. +// Lets a single test exercise both the 3.5.x (`...actuate.autoconfigure.metrics...`) and 4.0.x (`...micrometer.metrics.autoconfigure...`) layouts. +private fun resolveSpringBootClass(vararg candidateFqcns: String): Class<*> { + val classLoader = OkapiMicrometerAutoConfiguration::class.java.classLoader + return candidateFqcns.firstNotNullOfOrNull { fqcn -> + try { + Class.forName(fqcn, false, classLoader) + } catch (_: ClassNotFoundException) { + null + } + } ?: error("None of $candidateFqcns resolves on this Spring Boot runtime; check spring-boot-starter-actuator on the test classpath.") +} + private fun stubStore() = object : OutboxStore { override fun persist(entry: OutboxEntry) = entry override fun claimPending(limit: Int) = emptyList()