Skip to content

Commit 57cd652

Browse files
whitewhite
authored andcommitted
Support Spring Boot 3.x and 4.x HealthIndicator simultaneously
- Decouple PythonEmbedHealthIndicator from Spring Boot HealthIndicator API - Introduce HealthData record as version-agnostic health result - Add HealthIndicatorAdapter for Boot 3.x (Actuator HealthIndicator) - Add HealthIndicatorV4Adapter for Boot 4.x (reflection-based proxy) - Split auto-configuration into V3/V4 conditional bean registrations
1 parent 9cb926c commit 57cd652

7 files changed

Lines changed: 258 additions & 73 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
package io.github.howtis.pythonembed.spring;
2+
3+
import org.springframework.boot.actuate.health.Health;
4+
import org.springframework.boot.actuate.health.HealthIndicator;
5+
6+
import java.util.Map;
7+
8+
/**
9+
* Adapts a {@link PythonEmbedHealthIndicator} to Spring Boot 3.x
10+
* {@code org.springframework.boot.actuate.health.HealthIndicator}.
11+
*
12+
* <p>Registered by {@link PythonEmbedAutoConfiguration.HealthIndicatorConfigurationV3}
13+
* when Boot 3.x Actuator is on the classpath.
14+
*/
15+
final class HealthIndicatorAdapter implements HealthIndicator {
16+
17+
private final PythonEmbedHealthIndicator delegate;
18+
19+
HealthIndicatorAdapter(PythonEmbedHealthIndicator delegate) {
20+
this.delegate = delegate;
21+
}
22+
23+
@Override
24+
public Health health() {
25+
PythonEmbedHealthIndicator.HealthData data = delegate.health();
26+
if (data.up()) {
27+
Health.Builder builder = Health.up();
28+
for (Map.Entry<String, Object> entry : data.details().entrySet()) {
29+
builder.withDetail(entry.getKey(), entry.getValue());
30+
}
31+
return builder.build();
32+
}
33+
Health.Builder builder = Health.down();
34+
if (data.error() != null) {
35+
builder.withException(data.error());
36+
}
37+
for (Map.Entry<String, Object> entry : data.details().entrySet()) {
38+
builder.withDetail(entry.getKey(), entry.getValue());
39+
}
40+
return builder.build();
41+
}
42+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package io.github.howtis.pythonembed.spring;
2+
3+
import org.slf4j.Logger;
4+
import org.slf4j.LoggerFactory;
5+
6+
import java.lang.reflect.InvocationTargetException;
7+
import java.lang.reflect.Method;
8+
import java.lang.reflect.Proxy;
9+
import java.util.Map;
10+
11+
/**
12+
* Adapts a {@link PythonEmbedHealthIndicator} to Spring Boot 4.x
13+
* {@code org.springframework.boot.health.contributor.HealthIndicator} via reflection.
14+
*
15+
* <p>Because the starter compiles against Spring Boot 3.x, Boot 4.x types are not
16+
* available at compile time. This adapter uses {@link Proxy} and reflection to
17+
* bridge the gap at runtime when Boot 4.x is on the classpath.
18+
*
19+
* <p>Registered by {@link PythonEmbedAutoConfiguration.HealthIndicatorConfigurationV4}.
20+
*/
21+
final class HealthIndicatorV4Adapter {
22+
23+
private static final Logger log = LoggerFactory.getLogger(HealthIndicatorV4Adapter.class);
24+
25+
static final String HEALTH_INDICATOR_CLASS =
26+
"org.springframework.boot.health.contributor.HealthIndicator";
27+
static final String HEALTH_CLASS =
28+
"org.springframework.boot.health.contributor.Health";
29+
30+
private HealthIndicatorV4Adapter() {
31+
}
32+
33+
/**
34+
* Creates a dynamic proxy implementing Boot 4.x {@code HealthIndicator},
35+
* delegating to the given {@link PythonEmbedHealthIndicator}.
36+
*/
37+
static Object create(PythonEmbedHealthIndicator delegate) {
38+
try {
39+
Class<?> healthIndicatorClass = Class.forName(HEALTH_INDICATOR_CLASS);
40+
return Proxy.newProxyInstance(
41+
healthIndicatorClass.getClassLoader(),
42+
new Class<?>[]{healthIndicatorClass},
43+
(proxy, method, args) -> {
44+
if ("health".equals(method.getName())) {
45+
return buildHealth(delegate.health());
46+
}
47+
return method.invoke(proxy, args);
48+
});
49+
} catch (ClassNotFoundException e) {
50+
throw new IllegalStateException(
51+
"Spring Boot 4.x HealthIndicator not found on classpath", e);
52+
}
53+
}
54+
55+
private static Object buildHealth(PythonEmbedHealthIndicator.HealthData data) {
56+
try {
57+
Class<?> healthClass = Class.forName(HEALTH_CLASS);
58+
Method upMethod = healthClass.getMethod("up");
59+
Method downMethod = healthClass.getMethod("down");
60+
Method withDetailMethod = healthClass.getMethod("withDetail", String.class, Object.class);
61+
Method withExceptionMethod = healthClass.getMethod("withException", Throwable.class);
62+
Method buildMethod = healthClass.getMethod("build");
63+
64+
Object builder;
65+
if (data.up()) {
66+
builder = upMethod.invoke(null);
67+
} else {
68+
builder = downMethod.invoke(null);
69+
if (data.error() != null) {
70+
builder = withExceptionMethod.invoke(builder, data.error());
71+
}
72+
}
73+
for (Map.Entry<String, Object> entry : data.details().entrySet()) {
74+
builder = withDetailMethod.invoke(builder, entry.getKey(), entry.getValue());
75+
}
76+
return buildMethod.invoke(builder);
77+
} catch (ClassNotFoundException e) {
78+
throw new IllegalStateException(
79+
"Spring Boot 4.x Health class not found on classpath", e);
80+
} catch (NoSuchMethodException | IllegalAccessException e) {
81+
throw new IllegalStateException(
82+
"Failed to reflectively access Boot 4.x Health API", e);
83+
} catch (InvocationTargetException e) {
84+
Throwable cause = e.getCause();
85+
if (cause instanceof RuntimeException re) {
86+
throw re;
87+
}
88+
throw new IllegalStateException(
89+
"Boot 4.x Health builder threw an exception", cause);
90+
}
91+
}
92+
}

python-embed-spring-boot-starter/src/main/java/io/github/howtis/pythonembed/spring/PythonEmbedAutoConfiguration.java

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -146,25 +146,54 @@ public void destroy() {
146146
}
147147

148148
// ------------------------------------------------------------------
149-
// HealthIndicator (auto-detected when Actuator is on classpath)
149+
// HealthIndicator — Boot 3.x (Actuator on classpath)
150150
// ------------------------------------------------------------------
151151

152-
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
152+
@Bean
153153
@ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
154-
static class HealthIndicatorConfiguration {
154+
@ConditionalOnMissingBean
155+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true)
156+
PythonEmbedHealthIndicator pythonEmbedHealthIndicator(PythonEmbed embed) {
157+
return new PythonEmbedHealthIndicator.Single(embed);
158+
}
155159

156-
@Bean
157-
@ConditionalOnMissingBean
158-
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true)
159-
PythonEmbedHealthIndicator pythonEmbedHealthIndicator(PythonEmbed embed) {
160-
return new PythonEmbedHealthIndicator.Single(embed);
161-
}
160+
@Bean
161+
@ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
162+
@ConditionalOnMissingBean
163+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL")
164+
PythonEmbedHealthIndicator pythonEmbedPoolHealthIndicator(PythonEmbedPool pool) {
165+
return new PythonEmbedHealthIndicator.Pool(pool);
166+
}
162167

163-
@Bean
164-
@ConditionalOnMissingBean
165-
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL")
166-
PythonEmbedHealthIndicator pythonEmbedPoolHealthIndicator(PythonEmbedPool pool) {
167-
return new PythonEmbedHealthIndicator.Pool(pool);
168-
}
168+
@Bean
169+
@ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
170+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true)
171+
HealthIndicatorAdapter healthIndicatorAdapter(PythonEmbed embed) {
172+
return new HealthIndicatorAdapter(new PythonEmbedHealthIndicator.Single(embed));
173+
}
174+
175+
@Bean
176+
@ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
177+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL")
178+
HealthIndicatorAdapter healthIndicatorPoolAdapter(PythonEmbedPool pool) {
179+
return new HealthIndicatorAdapter(new PythonEmbedHealthIndicator.Pool(pool));
180+
}
181+
182+
// ------------------------------------------------------------------
183+
// HealthIndicator — Boot 4.x (HealthContributor on classpath)
184+
// ------------------------------------------------------------------
185+
186+
@Bean("pythonEmbedHealthIndicatorV4")
187+
@ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator")
188+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "SINGLE", matchIfMissing = true)
189+
Object healthIndicatorV4(PythonEmbed embed) {
190+
return HealthIndicatorV4Adapter.create(new PythonEmbedHealthIndicator.Single(embed));
191+
}
192+
193+
@Bean("pythonEmbedPoolHealthIndicatorV4")
194+
@ConditionalOnClass(name = "org.springframework.boot.health.contributor.HealthIndicator")
195+
@ConditionalOnProperty(prefix = "python-embed", name = "mode", havingValue = "POOL")
196+
Object healthIndicatorPoolV4(PythonEmbedPool pool) {
197+
return HealthIndicatorV4Adapter.create(new PythonEmbedHealthIndicator.Pool(pool));
169198
}
170199
}

python-embed-spring-boot-starter/src/main/java/io/github/howtis/pythonembed/spring/PythonEmbedHealthIndicator.java

Lines changed: 52 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,55 @@
55
import io.github.howtis.pythonembed.PythonExecutionException;
66
import org.slf4j.Logger;
77
import org.slf4j.LoggerFactory;
8-
import org.springframework.boot.actuate.health.Health;
9-
import org.springframework.boot.actuate.health.HealthIndicator;
8+
9+
import java.util.LinkedHashMap;
10+
import java.util.Map;
1011

1112
/**
12-
* Actuator health indicator for python-embed.
13+
* Health indicator logic for python-embed (Spring Boot version-agnostic).
14+
*
15+
* <p>Does not implement any Spring Boot health interface directly.
16+
* Instead, version-specific adapters ({@link HealthIndicatorAdapter} for
17+
* Boot 3.x, {@link HealthIndicatorV4Adapter} for Boot 4.x) wrap instances
18+
* of this class and expose them as the appropriate {@code HealthIndicator}.
1319
*
1420
* <p>SINGLE mode: calls {@link PythonEmbed#health()} -- UP if the
1521
* Python process responds without error.
1622
*
1723
* <p>POOL mode: checks that the pool has at least {@link PythonEmbedPool#minPool()}
1824
* live instances ({@link PythonEmbedPool#size()} &gt;= {@code minPool}).
1925
*/
20-
public sealed abstract class PythonEmbedHealthIndicator implements HealthIndicator {
26+
public sealed abstract class PythonEmbedHealthIndicator {
2127

2228
private static final Logger log = LoggerFactory.getLogger(PythonEmbedHealthIndicator.class);
2329

30+
/**
31+
* Version-agnostic health result.
32+
*
33+
* @param up whether the component is healthy
34+
* @param details key-value details (may be empty)
35+
* @param error the exception that caused the failure (or null)
36+
*/
37+
public record HealthData(boolean up, Map<String, Object> details, Exception error) {
38+
public HealthData {
39+
details = Map.copyOf(details);
40+
}
41+
42+
public static HealthData up(Map<String, Object> details) {
43+
return new HealthData(true, details, null);
44+
}
45+
46+
public static HealthData down(Exception error) {
47+
return new HealthData(false, Map.of(), error);
48+
}
49+
50+
public static HealthData down(Map<String, Object> details) {
51+
return new HealthData(false, details, null);
52+
}
53+
}
54+
55+
public abstract HealthData health();
56+
2457
static final class Single extends PythonEmbedHealthIndicator {
2558
private final PythonEmbed embed;
2659

@@ -29,20 +62,18 @@ static final class Single extends PythonEmbedHealthIndicator {
2962
}
3063

3164
@Override
32-
public Health health() {
65+
public HealthData health() {
3366
try {
3467
var info = embed.health();
35-
return Health.up()
36-
.withDetail("memoryRssKb", info.memoryRssKb())
37-
.withDetail("refCount", info.refCount())
38-
.withDetail("gcEnabled", info.gcEnabled())
39-
.withDetail("gcCounts", info.gcCounts())
40-
.build();
68+
Map<String, Object> details = new LinkedHashMap<>();
69+
details.put("memoryRssKb", info.memoryRssKb());
70+
details.put("refCount", info.refCount());
71+
details.put("gcEnabled", info.gcEnabled());
72+
details.put("gcCounts", info.gcCounts());
73+
return HealthData.up(details);
4174
} catch (PythonExecutionException e) {
4275
log.warn("PythonEmbed health check failed", e);
43-
return Health.down()
44-
.withException(e)
45-
.build();
76+
return HealthData.down(e);
4677
}
4778
}
4879
}
@@ -55,23 +86,20 @@ static final class Pool extends PythonEmbedHealthIndicator {
5586
}
5687

5788
@Override
58-
public Health health() {
89+
public HealthData health() {
5990
try {
6091
int size = pool.size();
6192
int min = pool.minPool();
6293
int active = pool.activeCount();
6394
boolean healthy = size >= min;
64-
var builder = healthy ? Health.up() : Health.down();
65-
return builder
66-
.withDetail("size", size)
67-
.withDetail("minPool", min)
68-
.withDetail("activeCount", active)
69-
.build();
95+
Map<String, Object> details = new LinkedHashMap<>();
96+
details.put("size", size);
97+
details.put("minPool", min);
98+
details.put("activeCount", active);
99+
return healthy ? HealthData.up(details) : HealthData.down(details);
70100
} catch (Exception e) {
71101
log.warn("PythonEmbedPool health check failed", e);
72-
return Health.down()
73-
.withException(e)
74-
.build();
102+
return HealthData.down(e);
75103
}
76104
}
77105
}

python-embed-spring-boot-starter/src/test/java/io/github/howtis/pythonembed/spring/PythonEmbedAutoConfigurationTest.java

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,6 @@ PythonEmbedPool mockPythonEmbedPool() {
4141

4242
@Test
4343
void contextLoadsInSingleMode() {
44-
// Mock beans prevent real PythonEmbed/PythonEmbedPool creation
45-
// (real creation would fail without Python installed).
46-
// The context should load without errors.
4744
contextRunner
4845
.withUserConfiguration(MockConfig.class)
4946
.withPropertyValues("python-embed.mode=SINGLE")
@@ -70,8 +67,8 @@ void singleModeHealthIndicatorCreatedWithActuator() {
7067
.withUserConfiguration(MockConfig.class)
7168
.withPropertyValues("python-embed.mode=SINGLE")
7269
.run(ctx -> {
73-
// Actuator IS on test classpath (spring-boot-starter-actuator)
7470
assertThat(ctx.containsBean("pythonEmbedHealthIndicator")).isTrue();
71+
assertThat(ctx.containsBean("healthIndicatorAdapter")).isTrue();
7572
});
7673
}
7774

@@ -82,6 +79,7 @@ void poolModeHealthIndicatorCreatedWithActuator() {
8279
.withPropertyValues("python-embed.mode=POOL")
8380
.run(ctx -> {
8481
assertThat(ctx.containsBean("pythonEmbedPoolHealthIndicator")).isTrue();
82+
assertThat(ctx.containsBean("healthIndicatorPoolAdapter")).isTrue();
8583
});
8684
}
8785

@@ -119,16 +117,11 @@ void poolCloserDestroyCallsPoolClose() {
119117

120118
@Test
121119
void userProvidedHealthIndicatorOverridesSingleMode() {
122-
// Mockito cannot mock sealed PythonEmbedHealthIndicator directly;
123-
// use the concrete Single subclass (this still satisfies @ConditionalOnMissingBean)
124120
contextRunner
125121
.withUserConfiguration(MockConfig.class, UserHealthIndicatorConfig.class)
126122
.withPropertyValues("python-embed.mode=SINGLE")
127123
.run(ctx -> {
128124
assertThat(ctx).hasNotFailed();
129-
// When a user provides their own indicator, the auto-configured one is skipped.
130-
// Here we get the single bean; it should be the user's config bean, not
131-
// the auto-configured one (which would use a different constructor).
132125
var bean = ctx.getBean(PythonEmbedHealthIndicator.class);
133126
assertThat(bean).isNotNull();
134127
});
@@ -151,7 +144,6 @@ static class UserHealthIndicatorConfig {
151144
@Bean
152145
@Primary
153146
PythonEmbedHealthIndicator customHealthIndicator() {
154-
// Use concrete subclass — sealed PythonEmbedHealthIndicator cannot be mocked
155147
return new PythonEmbedHealthIndicator.Single(mock(PythonEmbed.class));
156148
}
157149
}

0 commit comments

Comments
 (0)