Compare commits

..
11 Commits
Author SHA1 Message Date
Claude 008211f2fa Add file-transfer module: multipart limits, filename handling, Range downloads, streaming under -Xmx64m, S3 presigned URLs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01VRLbHYdXVEBzGVhYq3pz4q
2026-10-17 15:41:24 +00:00
Claude b9388053c4 Add rest-client-test module: @RestClientTest on Boot 4.1, Jackson 3 by default, and the RestClient-vs-RestTemplate root-URI matching trap (post #49) 2026-10-08 16:40:51 +00:00
Claude 4370ac7fdf Correct post #45: Flyway and Liquibase DO run under @DataJpaTest by default
The previous commit's FlywayDoesNotRunUnderPlainDataJpaTestTest and
FlywayRunsWhenExplicitlyImportedTest rested on a false premise: that none of
@DataJpaTest's four documented meta-annotations import FlywayAutoConfiguration,
so migrations never run under the slice. That reading of the four .imports
manifests is accurate but incomplete. @AutoConfigureJdbc is itself
meta-annotated with @AutoConfigureDataSourceInitialization (new in Boot 4.0),
and Spring's ImportAutoConfigurationImportSelector walks the full
meta-annotation tree transitively. That fifth annotation's own .imports
resource is loaded via ClassLoader.getResources(), which merges same-named
files from every jar on the classpath -- spring-boot-jdbc-test,
spring-boot-flyway and spring-boot-liquibase each ship one at the identical
path, contributing DataSourceInitializationAutoConfiguration,
FlywayAutoConfiguration and LiquibaseAutoConfiguration respectively.

Replaced the two wrong-premise test classes with FlywayRunsByDefaultTest
(proves the bean is present and the migration actually ran) and
ExplicitFlywayImportIsRedundantTest (proves the old "add the import yourself"
advice is harmless but unnecessary on Boot 4.1). Added javap/unzip evidence
for the new mechanism to docs/output, re-captured the full test run (9 tests,
1 clean skip), corrected README's test table, and closed a second,
unrelated gap: the unzip -l listing quoted in the @ServiceConnection section
had no matching captured transcript -- it does now.
2026-10-08 12:35:04 +00:00
Claude 97f946136b Add data-jpa-testcontainers module: @DataJpaTest's default substitution, the Flyway-never-runs trap, and @ServiceConnection on Testcontainers 2.0.5 (post #45) 2026-10-08 11:52:08 +00:00
Claude 5e4d481c02 Add rest-test-client module: RestTestClient across all four binding modes, TestRestTemplate relocation, gzip-hiding HTTP client surprise (post #44) 2026-10-08 09:40:13 +00:00
Claude 5851521420 Add testing-no-containers module: WireMock, MockWebServer, @MockitoBean/@MockitoSpyBean (post #43) 2026-10-08 06:19:52 +00:00
Claude f736108067 Add kotlin module: null safety, coroutines, allopen/noarg, JSpecify interop (post #42) 2026-10-08 05:35:15 +00:00
Claude 83d245e726 hexagonal: capture the real missing-scan-base-packages failure as a transcript
A follow-up to the hexagonal module commit: the post's self-correction pass caught a
console block quoting that exception from memory instead of a committed transcript, so
this captures it for real (by removing @EnableJpaRepositories/@EntityScan, running the
test, and restoring them) and links the README to it. A regular git commit --amend +
force-push would have kept this at one commit for the module, matching every other
module in this repo, but this session's git safety controls do not allow a force-push,
so this lands as its own small commit instead.
2026-10-03 20:35:59 +00:00
Claude fd6d0fa1e7 Add the hexagonal module: Hexagonal Architecture (Ports and Adapters) in Spring Boot 4 2026-10-03 20:28:16 +00:00
asmhatreandClaude Sonnet 5 ae681f85c4 Add batch-5-to-6 module: Spring Batch 5.2 to 6.0 migration on Boot 3.5 to 4.1
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 09:09:50 +00:00
asmhatreandClaude Sonnet 5 804c33f0d5 Add gradle9 module: Spring Boot 4 build migrated from Gradle 8 to Gradle 9
Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Gth99spB1CzQmBFofXmU4Z
2026-09-28 09:00:03 +00:00
206 changed files with 7575 additions and 3 deletions
+14 -3
View File
@@ -9,6 +9,7 @@ files.
| Directory | Article | What it demonstrates | | Directory | Article | What it demonstrates |
|---|---|---| |---|---|---|
| [`hexagonal/`](hexagonal) | [Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/) | a 4-module Maven reactor that makes "the core has no Spring dependency" something `mvn dependency:tree` proves rather than a naming convention, testing the core with zero Spring context, and a Spring Boot 4 test-slice-artifact split (`@DataJpaTest`, `@WebMvcTest`, `TestRestTemplate`) hit and fixed along the way |
| [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang | | [`actuator-in-production/`](actuator-in-production) | [Spring Boot Actuator in Production](https://ankurm.com/spring-boot-actuator-production-endpoints-security-health-indicators/) | endpoint exposure defaults, securing Actuator, custom health indicators and how they hang |
| [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache | | [`spring-boot-startup-time/`](spring-boot-startup-time) | [Why Your Spring Boot App Takes 8 Seconds to Start](spring-boot-startup-time/post/post.md) | `BufferingApplicationStartup`, JFR startup events, self time vs total time, the classpath-scan tax, the JDK 25 AOT cache |
| [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation | | [`configuration-properties/`](configuration-properties) | [@ConfigurationProperties vs @Value in Spring Boot 4](https://ankurm.com/) | relaxed binding measured three ways, record constructor binding, validation, IDE metadata generation |
@@ -38,10 +39,20 @@ files.
| [`multi-datasource/`](multi-datasource) | [Multiple DataSources in Spring Boot 4 with Spring Data JPA](https://ankurm.com/multiple-datasources-spring-boot-4-spring-data-jpa/) | two H2 databases each with its own `DataSource`, Flyway, `EntityManagerFactory` and transaction manager, six ways of getting the wiring wrong, plain vs named `@Transactional`, and `ChainedTransactionManager` under a failing commit | | [`multi-datasource/`](multi-datasource) | [Multiple DataSources in Spring Boot 4 with Spring Data JPA](https://ankurm.com/multiple-datasources-spring-boot-4-spring-data-jpa/) | two H2 databases each with its own `DataSource`, Flyway, `EntityManagerFactory` and transaction manager, six ways of getting the wiring wrong, plain vs named `@Transactional`, and `ChainedTransactionManager` under a failing commit |
| [`i18n/`](i18n) | [Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/) | English, Marathi and Hindi message bundles, `Accept-Language` vs a cookie resolver, apostrophes and Devanagari digits in `MessageFormat`, the JVM default locale as a hidden fallback, localized validation messages and `ProblemDetail` titles | | [`i18n/`](i18n) | [Internationalization (i18n) in Spring Boot 4: MessageSource, LocaleResolver and Localized ProblemDetail](https://ankurm.com/spring-boot-4-internationalization-messagesource-localeresolver-problemdetail/) | English, Marathi and Hindi message bundles, `Accept-Language` vs a cookie resolver, apostrophes and Devanagari digits in `MessageFormat`, the JVM default locale as a hidden fallback, localized validation messages and `ProblemDetail` titles |
| [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic | | [`aot-cache/`](aot-cache) | [Faster Spring Boot Startup with the JDK AOT Cache (JEP 514/515): Benchmarks vs CDS and Native](https://ankurm.com/spring-boot-jdk-aot-cache-jep-514-515-startup-benchmarks/) | the JDK 25 AOT cache trained two ways, against a plain JVM, AppCDS, Spring's own AOT output and a GraalVM native image (ten interleaved rounds each), first-request latency, 24 cases where the cache silently stops applying (jar, timestamp, JVM build, ZGC, compact headers), `-XX:AOTMode=on`, Docker layer arithmetic |
| [`kotlin/`](kotlin) | [Kotlin with Spring Boot 4.1: Null Safety, Coroutines, and What Java Developers Get Wrong](https://ankurm.com/) | Kotlin 2.3.21 (Boot 4.1.1's own managed version, not the latest GA), the real `@Configuration class may not be final` and `@Bean method must not be private or final` failures without the allopen/noarg `spring`/`jpa` compiler plugins, `suspend fun`/`Flow<T>` controller methods proven to still resume off a Tomcat thread under plain `spring-boot-starter-webmvc`, a JSpecify `@NullMarked` Java method that still NPEs at runtime because the contract isn't runtime-checked, and `TestRestTemplate` confirmed absent from Boot 4.1's `spring-boot-test` jar |
| [`testing-no-containers/`](testing-no-containers) | [Testing Spring Boot 4 Without Containers: WireMock, MockWebServer and @MockitoBean](https://ankurm.com/) | `@MockBean`'s total removal proven with a real `javac` failure, `@MockitoBean`/`@MockitoSpyBean` context-cache behavior proven with real `org.springframework.test.context.cache` DEBUG log lines, WireMock fault injection against a real `RestClient`, a real `NoClassDefFoundError` from mixing `wiremock` with `wiremock-jetty12` (fixed with `wiremock-standalone`), and MockWebServer's `SocketEffect.ShutdownConnection` for genuinely severing a connection mid-response |
| [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` |
| [`file-transfer/`](file-transfer) | [File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/) | multipart limits enforced by Tomcat (and ignored by `MockMvc`), the client-controlled filename, `Range` downloads, a 200 MB download and 150 MB upload under `-Xmx64m`, and S3 presigned URLs checked against a local SigV4 verifier (not a real S3) |
`core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` are the exception: they have **no `docs/` folder**. Their deeper material lives in `core-di`, `core-beans`, `custom-starter`, `core-events`, `multi-datasource`, `i18n`, `aot-cache`, `kotlin`, `testing-no-containers`, `rest-test-client` and `hexagonal` are the exception: they have **no `docs/NN-topic.md` chapter folder**. Their deeper material lives in
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/` collapsible sections inside the articles themselves. Where their captured output sits is not
directory instead of `docs/output/`. uniform, and is worth checking per module rather than assuming: `core-di`, `core-beans`,
`custom-starter`, `core-events`, `multi-datasource`, `i18n` and `aot-cache` keep it in a
top-level `output/` directory (`hexagonal` is also the one multi-module reactor in this repo,
so its output sits one level deeper still, under each module: `core/output/`,
`persistence-adapter/output/`, and so on) — but `kotlin`, `testing-no-containers` and
`rest-test-client` still use `docs/output/`, same as the chaptered modules, just without the
`docs/NN-topic.md` chapters themselves.
Articles whose text is kept here rather than only on the blog have it under Articles whose text is kept here rather than only on the blog have it under
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and `<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
+1
View File
@@ -0,0 +1 @@
target/
+13
View File
@@ -0,0 +1,13 @@
# batch-5-to-6: Spring Batch 5 to 6 on Spring Boot 3.5 to 4.1
Companion code for the ankurm.com post "Spring Batch 5 to 6 Migration Guide".
| Folder | What it is |
|---|---|
| `legacy/` | A one-job app on Boot 3.5.16 / Batch 5.2.6 (JDK 21) using the Batch 5 API |
| `migrated/` | The same app on Boot 4.1.1 / Batch 6.0.5 (JDK 25) using the Batch 6 API |
| `scripts/run-all.sh` | Builds the intermediate stages from `legacy/` and regenerates every file in `output/` |
| `output/` | Transcripts: compile errors, startup failure, deprecation warnings, the silent listener change, and the final run |
Batch 6.0.5 is the latest GA; 6.1.0-M1 and 6.1.0-M2 are milestones and are not tested here.
Tested on 28 September 2026.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.16</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>batch-legacy</artifactId>
<version>1.0.0</version>
<properties><java.version>21</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-batch</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.batch</groupId><artifactId>spring-batch-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.ankurm.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class App {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
ctx.getBean(JobRunner.class).run();
ctx.close();
}
}
@@ -0,0 +1,55 @@
package com.ankurm.batch;
import java.util.List;
import org.springframework.batch.core.ChunkListener;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.support.ListItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class BatchConfig {
@Bean
ItemReader<Integer> reader() {
return new ListItemReader<>(List.of(1, 2, 3, 4, 5, 6, 7));
}
@Bean
ItemWriter<String> writer() {
return chunk -> System.out.println("WRITE " + chunk.getItems());
}
@Bean
ChunkListener chunkListener() {
return new ChunkListener() {
@Override public void afterChunk(ChunkContext context) {
System.out.println("CHUNK DONE, read so far=" + context.getStepContext().getStepExecution().getReadCount());
}
};
}
@Bean
Step squareStep(JobRepository repo, PlatformTransactionManager tx, ChunkListener listener) {
return new StepBuilder("squareStep", repo)
.<Integer, String>chunk(3, tx)
.reader(reader())
.processor(i -> "sq" + (i * i))
.writer(writer())
.listener(listener)
.build();
}
@Bean
Job squareJob(JobRepository repo, Step squareStep) {
return new JobBuilder("squareJob", repo).start(squareStep).build();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.batch;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.stereotype.Component;
@Component
public class JobRunner {
private final JobLauncher launcher;
private final JobExplorer explorer;
private final Job job;
public JobRunner(JobLauncher launcher, JobExplorer explorer, Job squareJob) {
this.launcher = launcher; this.explorer = explorer; this.job = squareJob;
}
public void run() {
try {
JobParameters params = new JobParametersBuilder().addLong("run.id", 1L).toJobParameters();
JobExecution exec = launcher.run(job, params);
System.out.println("STATUS " + exec.getStatus() + " exit=" + exec.getExitStatus().getExitCode());
System.out.println("INSTANCES " + explorer.getJobInstanceCount("squareJob"));
System.out.println("DONE " + (exec.getStatus() == BatchStatus.COMPLETED));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,3 @@
spring.batch.job.enabled=false
spring.datasource.url=jdbc:h2:mem:legacy;DB_CLOSE_DELAY=-1
spring.batch.jdbc.initialize-schema=always
@@ -0,0 +1,23 @@
package com.ankurm.batch;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@SpringBatchTest
class JobTest {
@Autowired JobLauncherTestUtils utils;
@Test
void jobCompletes() throws Exception {
JobExecution exec = utils.launchJob();
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>batch-migrated</artifactId>
<version>1.0.0</version>
<properties><java.version>25</java.version></properties>
<dependencies>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-batch-jdbc</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
<dependency><groupId>org.springframework.batch</groupId><artifactId>spring-batch-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
</project>
@@ -0,0 +1,14 @@
package com.ankurm.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
@SpringBootApplication
public class App {
public static void main(String[] args) {
ConfigurableApplicationContext ctx = SpringApplication.run(App.class, args);
ctx.getBean(JobRunner.class).run();
ctx.close();
}
}
@@ -0,0 +1,56 @@
package com.ankurm.batch;
import java.util.List;
import org.springframework.batch.core.listener.ChunkListener;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.step.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.infrastructure.item.Chunk;
import org.springframework.batch.infrastructure.item.ItemReader;
import org.springframework.batch.infrastructure.item.ItemWriter;
import org.springframework.batch.infrastructure.item.support.ListItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class BatchConfig {
@Bean
ItemReader<Integer> reader() {
return new ListItemReader<>(List.of(1, 2, 3, 4, 5, 6, 7));
}
@Bean
ItemWriter<String> writer() {
return chunk -> System.out.println("WRITE " + chunk.getItems());
}
@Bean
ChunkListener<Integer, String> chunkListener() {
return new ChunkListener<>() {
@Override public void afterChunk(Chunk<String> chunk) {
System.out.println("CHUNK DONE, items in chunk=" + chunk.size());
}
};
}
@Bean
Step squareStep(JobRepository repo, PlatformTransactionManager tx, ChunkListener<Integer, String> listener) {
return new StepBuilder("squareStep", repo)
.<Integer, String>chunk(3)
.transactionManager(tx)
.reader(reader())
.processor(i -> "sq" + (i * i))
.writer(writer())
.listener(listener)
.build();
}
@Bean
Job squareJob(JobRepository repo, Step squareStep) {
return new JobBuilder("squareJob", repo).start(squareStep).build();
}
}
@@ -0,0 +1,33 @@
package com.ankurm.batch;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.job.Job;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.core.job.parameters.JobParameters;
import org.springframework.batch.core.job.parameters.JobParametersBuilder;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.stereotype.Component;
@Component
public class JobRunner {
private final JobOperator operator;
private final JobRepository repository;
private final Job job;
public JobRunner(JobOperator operator, JobRepository repository, Job squareJob) {
this.operator = operator; this.repository = repository; this.job = squareJob;
}
public void run() {
try {
JobParameters params = new JobParametersBuilder().addLong("run.id", 1L).toJobParameters();
JobExecution exec = operator.start(job, params);
System.out.println("STATUS " + exec.getStatus() + " exit=" + exec.getExitStatus().getExitCode());
System.out.println("INSTANCES " + repository.getJobInstanceCount("squareJob"));
System.out.println("DONE " + (exec.getStatus() == BatchStatus.COMPLETED));
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
}
@@ -0,0 +1,3 @@
spring.batch.job.enabled=false
spring.datasource.url=jdbc:h2:mem:legacy;DB_CLOSE_DELAY=-1
spring.batch.jdbc.initialize-schema=always
@@ -0,0 +1,23 @@
package com.ankurm.batch;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.job.JobExecution;
import org.springframework.batch.test.JobOperatorTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
@SpringBatchTest
class JobTest {
@Autowired JobOperatorTestUtils utils;
@Test
void jobCompletes() throws Exception {
JobExecution exec = utils.startJob();
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
}
}
+15
View File
@@ -0,0 +1,15 @@
$ mvn test # Boot 3.5.16, Batch 5.2.6, JDK 21
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 3.750 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, read so far=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, read so far=6
WRITE [sq49]
CHUNK DONE, read so far=7
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
@@ -0,0 +1,10 @@
$ mvn compile # legacy sources, only the Boot version changed to 4.1.1 (Batch 6.0.5)
compile error lines reported: 56
packages that no longer exist:
package org.springframework.batch.core.explore does not exist
package org.springframework.batch.item does not exist
package org.springframework.batch.item.support does not exist
classes that moved:
ChunkListener ItemReader ItemWriter Job JobExecution JobExplorer JobParameters JobParametersBuilder ListItemReader Step
@@ -0,0 +1,19 @@
$ mvn package # imports ported to the new packages, dependencies unchanged (spring-boot-starter-batch)
[WARNING] s2/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/BatchConfig.java:[43,17] <I,O>chunk(int,org.springframework.transaction.PlatformTransactionManager) in org.springframework.batch.core.step.builder.StepBuilder has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s2/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s2/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s2/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
APPLICATION FAILED TO START
***************************
Description:
Parameter 1 of method squareStep in com.ankurm.batch.BatchConfig required a bean of type 'org.springframework.transaction.PlatformTransactionManager' that could not be found.
@@ -0,0 +1,23 @@
$ mvn package # + spring-boot-starter-batch-jdbc and spring-boot-starter-jdbc
[WARNING] s3/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/BatchConfig.java:[43,17] <I,O>chunk(int,org.springframework.transaction.PlatformTransactionManager) in org.springframework.batch.core.step.builder.StepBuilder has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s3/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s3/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s3/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.571 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, read so far=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, read so far=6
WRITE [sq49]
CHUNK DONE, read so far=7
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
@@ -0,0 +1,17 @@
$ mvn package # chunk(3, tx) replaced by chunk(3).transactionManager(tx); listener untouched
[WARNING] s4/src/main/java/com/ankurm/batch/BatchConfig.java:[34,35] afterChunk(org.springframework.batch.core.scope.context.ChunkContext) in org.springframework.batch.core.listener.ChunkListener has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[14,19] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[15,19] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[18,22] org.springframework.batch.core.launch.JobLauncher in org.springframework.batch.core.launch has been deprecated and marked for removal
[WARNING] s4/src/main/java/com/ankurm/batch/JobRunner.java:[18,44] org.springframework.batch.core.repository.explore.JobExplorer in org.springframework.batch.core.repository.explore has been deprecated and marked for removal
[WARNING] s4/src/test/java/com/ankurm/batch/JobTest.java:[16,16] org.springframework.batch.test.JobLauncherTestUtils in org.springframework.batch.test has been deprecated and marked for removal
[WARNING] s4/src/test/java/com/ankurm/batch/JobTest.java:[20,34] launchJob() in org.springframework.batch.test.JobLauncherTestUtils has been deprecated and marked for removal
[INFO] BUILD SUCCESS
$ java -jar target/batch-legacy-1.0.0.jar
WRITE [sq1, sq4, sq9]
WRITE [sq16, sq25, sq36]
WRITE [sq49]
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
+15
View File
@@ -0,0 +1,15 @@
$ mvn package # final migrated project
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.029 s -- in com.ankurm.batch.JobTest
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS
$ java -jar target/batch-migrated-1.0.0.jar
WRITE [sq1, sq4, sq9]
CHUNK DONE, items in chunk=3
WRITE [sq16, sq25, sq36]
CHUNK DONE, items in chunk=3
WRITE [sq49]
CHUNK DONE, items in chunk=1
STATUS COMPLETED exit=COMPLETED
INSTANCES 1
DONE true
+13
View File
@@ -0,0 +1,13 @@
$ javap (Batch 6.0.5 jars), selected lines
public interface core.launch.JobOperator extends core.launch.JobLauncher {
public interface core.repository.JobRepository extends core.repository.explore.JobExplorer {
public class test.JobOperatorTestUtils extends test.JobLauncherTestUtils {
public <I, O> core.step.builder.SimpleStepBuilder<I, O> chunk(int, PlatformTransactionManager);
public <I, O> core.step.builder.ChunkOrientedStepBuilder<I, O> chunk(int);
public <I, O> core.step.builder.SimpleStepBuilder<I, O> chunk(infrastructure.repeat.CompletionPolicy, PlatformTransactionManager);
public default void beforeChunk(core.scope.context.ChunkContext);
public default void afterChunk(core.scope.context.ChunkContext);
public default void afterChunkError(core.scope.context.ChunkContext);
public default void beforeChunk(infrastructure.item.Chunk<I>);
public default void afterChunk(infrastructure.item.Chunk<O>);
public default void onChunkError(java.lang.Exception, infrastructure.item.Chunk<O>);
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Regenerates output/*.txt. Needs Maven, JDK 21 (J21) and JDK 25 (J25); network for Maven Central.
set -u
HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/b56-work}
J21=${J21:-/usr/lib/jvm/java-21-openjdk-amd64}; J25=${J25:-/tmp/tools/jdk-25.0.4.1+1}; MVN=${MVN:-mvn}
mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
mv_() { local jh=$1 d=$2; shift 2; (cd "$d" && JAVA_HOME=$jh $MVN -B -Dmaven.compiler.showDeprecation=true "$@" 2>&1 | grep -v JAVA_TOOL_OPTIONS | sed "s#$W/##g"); }
runjar() { local jh=$1 jar=$2; JAVA_HOME=$jh $jh/bin/java -jar "$jar" 2>&1 | grep -E '^(WRITE|CHUNK|STATUS|INSTANCES|DONE)'; }
stage() { rm -rf "$W/$1"; cp -r "$HERE/$2" "$W/$1"; rm -rf "$W/$1/target"; }
PORT_IMPORTS=$(cat <<'PY'
import glob,sys
m={'org.springframework.batch.core.ChunkListener':'org.springframework.batch.core.listener.ChunkListener',
'org.springframework.batch.core.Job;':'org.springframework.batch.core.job.Job;',
'org.springframework.batch.core.Step;':'org.springframework.batch.core.step.Step;',
'org.springframework.batch.core.JobExecution;':'org.springframework.batch.core.job.JobExecution;',
'org.springframework.batch.core.JobParameters;':'org.springframework.batch.core.job.parameters.JobParameters;',
'org.springframework.batch.core.JobParametersBuilder':'org.springframework.batch.core.job.parameters.JobParametersBuilder',
'org.springframework.batch.core.explore.JobExplorer':'org.springframework.batch.core.repository.explore.JobExplorer',
'org.springframework.batch.item.':'org.springframework.batch.infrastructure.item.'}
for f in glob.glob(sys.argv[1]+'/src/**/*.java',recursive=True):
s=open(f).read()
for a,b in m.items(): s=s.replace(a,b)
open(f,'w').write(s)
PY
)
# 01 legacy: Boot 3.5.16 / Batch 5.2.6
stage s0 legacy
{ echo '$ mvn test # Boot 3.5.16, Batch 5.2.6, JDK 21'; mv_ "$J21" "$W/s0" package | grep -E 'Tests run:|BUILD'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J21" "$W/s0/target/batch-legacy-1.0.0.jar"; } > "$OUT/01-legacy-batch5.txt"
# 02 same sources, Boot 4.1.1
stage s1 legacy; sed -i 's/3.5.16/4.1.1/; s/<java.version>21/<java.version>25/' "$W/s1/pom.xml"
{ echo '$ mvn compile # legacy sources, only the Boot version changed to 4.1.1 (Batch 6.0.5)'
mv_ "$J25" "$W/s1" compile > "$W/s1.log"
echo "compile error lines reported: $(grep -c '^\[ERROR\] .*\.java' "$W/s1.log")"; echo; echo 'packages that no longer exist:'
grep -oE 'package [a-z.]+ does not exist' "$W/s1.log" | sort -u; echo; echo 'classes that moved:'
grep -oE 'symbol: +class [A-Za-z]+' "$W/s1.log" | awk '{print $3}' | sort -u | tr '\n' ' '; echo; } > "$OUT/02-compile-errors-after-bump.txt"
# 03 imports ported, dependencies untouched
cp -r "$W/s1" "$W/s2"; rm -rf "$W/s2/target"; python3 -c "$PORT_IMPORTS" "$W/s2"
{ echo '$ mvn package # imports ported to the new packages, dependencies unchanged (spring-boot-starter-batch)'
mv_ "$J25" "$W/s2" -DskipTests package | grep -E 'WARNING.*\.java|BUILD|ERROR.*\.java'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'
JAVA_HOME=$J25 $J25/bin/java -jar "$W/s2/target/batch-legacy-1.0.0.jar" 2>&1 | grep -A6 'APPLICATION FAILED'; } > "$OUT/03-imports-ported-no-transaction-manager.txt"
# 04 + JDBC starter
cp -r "$W/s2" "$W/s3"; rm -rf "$W/s3/target"; python3 - "$W/s3/pom.xml" <<'PY'
import sys; p=sys.argv[1]; s=open(p).read()
s=s.replace('<artifactId>spring-boot-starter-batch</artifactId></dependency>','<artifactId>spring-boot-starter-batch-jdbc</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>')
open(p,'w').write(s)
PY
{ echo '$ mvn package # + spring-boot-starter-batch-jdbc and spring-boot-starter-jdbc'
mv_ "$J25" "$W/s3" package | grep -E 'WARNING.*\.java|Tests run:|BUILD'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J25" "$W/s3/target/batch-legacy-1.0.0.jar"; } > "$OUT/04-jdbc-starter-added.txt"
# 05 new chunk API but old listener
cp -r "$W/s3" "$W/s4"; rm -rf "$W/s4/target"; sed -i 's/\.<Integer, String>chunk(3, tx)/.<Integer, String>chunk(3)\n .transactionManager(tx)/' "$W/s4/src/main/java/com/ankurm/batch/BatchConfig.java"
{ echo '$ mvn package # chunk(3, tx) replaced by chunk(3).transactionManager(tx); listener untouched'
mv_ "$J25" "$W/s4" -DskipTests package | grep -E 'WARNING.*\.java|BUILD|ERROR.*\.java'
echo; echo '$ java -jar target/batch-legacy-1.0.0.jar'; runjar "$J25" "$W/s4/target/batch-legacy-1.0.0.jar"; } > "$OUT/05-new-chunk-api-listener-silent.txt"
# 06 final migrated
stage m migrated
{ echo '$ mvn package # final migrated project'
mv_ "$J25" "$W/m" package | grep -E 'WARNING.*\.java|Tests run:|BUILD'
echo; echo '$ java -jar target/batch-migrated-1.0.0.jar'; runjar "$J25" "$W/m/target/batch-migrated-1.0.0.jar"; } > "$OUT/06-migrated.txt"
# 07 API facts from the jars
M2=${M2:-$HOME/.m2/repository/org/springframework/batch}
CP=$M2/spring-batch-core/6.0.5/spring-batch-core-6.0.5.jar:$M2/spring-batch-infrastructure/6.0.5/spring-batch-infrastructure-6.0.5.jar:$M2/spring-batch-test/6.0.5/spring-batch-test-6.0.5.jar
{ echo '$ javap (Batch 6.0.5 jars), selected lines'
for c in org.springframework.batch.core.launch.JobOperator org.springframework.batch.core.repository.JobRepository org.springframework.batch.test.JobOperatorTestUtils; do env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP $c 2>&1 | grep -E '^public (interface|class)' | sed 's/org\.springframework\.batch\.//g'; done
env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP org.springframework.batch.core.step.builder.StepBuilder 2>&1 | grep -E ' chunk\(' | sed 's/org\.springframework\.batch\.//g; s/org\.springframework\.transaction\.//g; s/org\.springframework\.//g'
env -u JAVA_TOOL_OPTIONS $J25/bin/javap -cp $CP org.springframework.batch.core.listener.ChunkListener 2>&1 | grep -E 'Chunk(Context|<)' | sed 's/org\.springframework\.batch\.//g'; } > "$OUT/07-api-facts.txt"
echo done
+76
View File
@@ -0,0 +1,76 @@
# data-jpa-testcontainers
Companion module for [`@DataJpaTest` with Testcontainers `@ServiceConnection` on Spring Boot 4.1](https://ankurm.com/) on ankurm.com.
## What this is
Six JUnit 5 test classes against one JPA repository (`Product`), each isolating one fact about
what `@DataJpaTest`'s default embedded-database substitution actually does, what
`@ServiceConnection` wires in its place, and what the defaults do run that you might not expect
(Flyway migrations, as it turns out, are one of them). No `docs/NN-topic.md` chapters —
everything beyond what fits in the post itself is in the Javadoc on these six test classes and in
the captured transcripts below.
**Correction, made before this module was published:** an earlier draft of two of these classes
asserted that `@DataJpaTest` never runs Flyway or Liquibase migrations, on the strength of reading
its four documented meta-annotations' `.imports` manifests and finding no mention of either tool.
That reading was accurate and incomplete — see `FlywayRunsByDefaultTest`'s Javadoc and
`docs/output/06-autoconfigurejdbc-meta-annotation-javap.txt` for the fifth, undocumented-for-this-slice
meta-annotation that actually wires them in. The two test classes below that carried the wrong
premise (`FlywayDoesNotRunUnderPlainDataJpaTestTest`, `FlywayRunsWhenExplicitlyImportedTest`) were
replaced, not patched, with `FlywayRunsByDefaultTest` and `ExplicitFlywayImportIsRedundantTest`.
**This module was built and tested in a sandbox with no Docker daemon available.** Five of the six
test classes run and pass (or cleanly skip) without one. The sixth demonstrates, on purpose, the
real failure you get from the one case that genuinely needs a container. See the versions table and
the test table below for exactly which is which.
## Versions
| Dependency | Version | Verified against |
|---|---|---|
| Spring Boot | 4.1.1 | `spring-boot-dependencies-4.1.1.pom` |
| Testcontainers | 2.0.5 | same BOM; `testcontainers-bom-2.0.5.pom` |
| Flyway | 12.4.0 | same BOM |
| Hibernate ORM | 7.4.5.Final | runtime log, this module's own test run |
| H2 | 2.4.240 | runtime log, this module's own test run |
| JDK | 25 (Temurin) | `java -version`, this module's own build |
## Quickstart
```bash
mvn test # 9 tests run; 1 skips cleanly
mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest # the one deliberate failure
```
## Test classes
| Class | What it proves | Needs Docker | Outcome here |
|---|---|---|---|
| `DefaultReplacementTest` | Plain `@DataJpaTest` substitutes an embedded H2 DataSource, and each test method is rolled back independently of execution order | No | Pass |
| `FlywayRunsByDefaultTest` | A plain `@DataJpaTest`, with flyway-core and `spring-boot-starter-flyway` on the (test) classpath, runs the real Flyway migration by default — no `@ImportAutoConfiguration` needed, because `@AutoConfigureJdbc` is itself meta-annotated with `@AutoConfigureDataSourceInitialization`, which merges contributions from every jar on the classpath that ships one | No | Pass |
| `ExplicitFlywayImportIsRedundantTest` | Explicitly re-adding `@ImportAutoConfiguration(FlywayAutoConfiguration.class)` — the old advice for the (incorrect) premise above — changes nothing: still exactly one `flyway` bean, still exactly one applied migration | No | Pass |
| `NoSubstitutionStillSilentlyUsesH2Test` | `@AutoConfigureTestDatabase(replace = Replace.NONE)` alone is not a guarantee of a real connection — with nothing else configured, ordinary `DataSourceAutoConfiguration` still silently hands you embedded H2 | No | Pass |
| `ServiceConnectionSkipsCleanlyTest` | The intended shape: `@ServiceConnection` + `@Container` + `replace = Replace.NONE`, guarded by Testcontainers 2.0's new `@EnabledIfDockerAvailable` | Yes (guarded) | Skipped |
| `ServiceConnectionWithoutGuardFailsToStartTest` | The same class, minus the guard | Yes (unguarded) | **Fails — on purpose.** Excluded from `mvn test` by name in `pom.xml`; run it explicitly |
## Captured output
| File | Contents |
|---|---|
| `docs/output/00-full-test-run.txt` | `mvn clean test` — 9 tests, 1 skip, build green |
| `docs/output/01-postgresqlcontainer-relocation-javap.txt` | `javap` proof that `org.testcontainers.postgresql.PostgreSQLContainer` (2.0.5's new home) and the deprecated `org.testcontainers.containers.PostgreSQLContainer` shim live in the *same* jar, and that the old class alone keeps the self-referential `<SELF extends ...>` generic |
| `docs/output/02-serviceconnection-unaffected-javap.txt` | `javap` proof that `JdbcContainerConnectionDetailsFactory` is typed against `org.testcontainers.containers.JdbcDatabaseContainer<?>` — unmoved by the 2.0 package split, which is why `@ServiceConnection` needed no changes |
| `docs/output/04-service-connection-without-guard-fails.txt` | The real `ContainerFetchException` / `IllegalStateException` from trying to start a Postgres container with no Docker daemon present, and no guard against that |
| `docs/output/05-service-connection-skips-cleanly.txt` | The same absence of Docker, this time behind `@EnabledIfDockerAvailable` — one line, `Skipped: 1`, no failure |
| `docs/output/06-autoconfigurejdbc-meta-annotation-javap.txt` | `javap` proof that `AutoConfigureJdbc.class` is meta-annotated with `@AutoConfigureDataSourceInitialization`, plus the matching `META-INF/spring/...AutoConfigureDataSourceInitialization.imports` file contents from both `spring-boot-jdbc-test` and `spring-boot-flyway` — the actual mechanism behind `FlywayRunsByDefaultTest` |
## What's sourced from code, not run here
Two things this article discusses are backed by reading Spring Boot's and Testcontainers' own
class files and import manifests rather than by a run in this sandbox, and are called out as such
in the post itself: what a *successful* real-Postgres run under `@ServiceConnection` would actually
return once a Docker daemon is present (the wiring is verified; the live round trip is not), and
Testcontainers' reusable-container feature (`.withReuse(true)` plus `~/.testcontainers.properties`),
which has nothing to do with Docker availability but was out of scope for a module built in one
sitting.
@@ -0,0 +1,185 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.5.0:clean (default-clean) @ data-jpa-testcontainers ---
[INFO] Deleting /home/claude/spring-boot-demo/data-jpa-testcontainers/target
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 3 source files with javac [debug parameters release 25] to target/classes
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 6 source files with javac [debug parameters release 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.datajpa.DefaultReplacementTest
17:55:42.824 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.DefaultReplacementTest]: DefaultReplacementTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:55:43.042 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.DefaultReplacementTest
17:55:43.046 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.DefaultReplacementTest]: DefaultReplacementTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:55:43.073 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.DefaultReplacementTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-08T17:55:43.593+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : Starting DefaultReplacementTest using Java 25.0.4.1 with PID 10940 (started by root in /home/claude/spring-boot-demo/data-jpa-testcontainers)
2026-10-08T17:55:43.595+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : No active profile set, falling back to 1 default profile: "default"
2026-10-08T17:55:44.217+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-08T17:55:44.349+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 118 ms. Found 1 JPA repository interface.
2026-10-08T17:55:44.495+05:30 INFO 10940 --- [ main] beddedDataSourceBeanFactoryPostProcessor : Replacing 'dataSource' DataSource bean with embedded version
2026-10-08T17:55:44.784+05:30 INFO 10940 --- [ main] o.s.j.d.e.EmbeddedDatabaseFactory : Starting embedded database: url='jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=false', username='sa'
2026-10-08T17:55:45.448+05:30 INFO 10940 --- [ main] org.flywaydb.core.FlywayExecutor : Database: jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5 (H2 2.4)
2026-10-08T17:55:45.514+05:30 WARN 10940 --- [ main] o.f.c.internal.database.base.Database : Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232.
2026-10-08T17:55:45.551+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Schema history table "PUBLIC"."flyway_schema_history" does not exist yet
2026-10-08T17:55:45.555+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.014s)
2026-10-08T17:55:45.582+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table "PUBLIC"."flyway_schema_history" ...
2026-10-08T17:55:45.640+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": << Empty Schema >>
2026-10-08T17:55:45.652+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create product"
2026-10-08T17:55:45.750+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.016s)
2026-10-08T17:55:46.001+05:30 INFO 10940 --- [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-08T17:55:46.079+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-10-08T17:55:46.707+05:30 INFO 10940 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-08T17:55:46.832+05:30 INFO 10940 --- [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:4440be02-551f-486e-a4b1-2182bb84c7a5]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 4440BE02-551F-486E-A4B1-2182BB84C7A5/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-08T17:55:47.973+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-08T17:55:47.989+05:30 INFO 10940 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-08T17:55:48.156+05:30 INFO 10940 --- [ main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-10-08T17:55:48.476+05:30 INFO 10940 --- [ main] c.ankurm.datajpa.DefaultReplacementTest : Started DefaultReplacementTest in 5.325 seconds (process running for 7.158)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
WARNING: A Java agent has been loaded dynamically (/root/.m2/repository/net/bytebuddy/byte-buddy-agent/1.18.11/byte-buddy-agent-1.18.11.jar)
WARNING: If a serviceability tool is in use, please run with -XX:+EnableDynamicAgentLoading to hide this warning
WARNING: If a serviceability tool is not in use, please run with -Djdk.instrument.traceUsage for more information
WARNING: Dynamic loading of agents will be disallowed by default in a future release
Hibernate: select count(*) from product p1_0
Hibernate: select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.sku=?
JDBC reports database product name: H2
Hibernate: insert into product (name,sku,id) values (?,?,default)
Hibernate: select p1_0.id,p1_0.name,p1_0.sku from product p1_0 where p1_0.sku=?
Hibernate: select count(*) from product p1_0
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 7.500 s -- in com.ankurm.datajpa.DefaultReplacementTest
[INFO] Running com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
2026-10-08T17:55:50.002+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test]: NoSubstitutionStillSilentlyUsesH2Test does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:50.040+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
2026-10-08T17:55:50.041+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test]: NoSubstitutionStillSilentlyUsesH2Test does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:50.069+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-08T17:55:50.166+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : Starting NoSubstitutionStillSilentlyUsesH2Test using Java 25.0.4.1 with PID 10940 (started by root in /home/claude/spring-boot-demo/data-jpa-testcontainers)
2026-10-08T17:55:50.167+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : No active profile set, falling back to 1 default profile: "default"
2026-10-08T17:55:50.366+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-08T17:55:50.381+05:30 INFO 10940 --- [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 12 ms. Found 1 JPA repository interface.
2026-10-08T17:55:50.538+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-10-08T17:55:50.563+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn9: url=jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31 user=SA
2026-10-08T17:55:50.566+05:30 INFO 10940 --- [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-10-08T17:55:50.573+05:30 INFO 10940 --- [ main] org.flywaydb.core.FlywayExecutor : Database: jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31 (H2 2.4)
2026-10-08T17:55:50.597+05:30 WARN 10940 --- [ main] o.f.c.internal.database.base.Database : Using H2 2.4.240 which is newer than the version Flyway has been verified with. The latest verified version of H2 is 2.3.232.
2026-10-08T17:55:50.607+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Schema history table "PUBLIC"."flyway_schema_history" does not exist yet
2026-10-08T17:55:50.608+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbValidate : Successfully validated 1 migration (execution time 00:00.005s)
2026-10-08T17:55:50.610+05:30 INFO 10940 --- [ main] o.f.c.i.s.JdbcTableSchemaHistory : Creating Schema History table "PUBLIC"."flyway_schema_history" ...
2026-10-08T17:55:50.723+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Current version of schema "PUBLIC": << Empty Schema >>
2026-10-08T17:55:50.735+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Migrating schema "PUBLIC" to version "1 - create product"
2026-10-08T17:55:50.745+05:30 INFO 10940 --- [ main] o.f.core.internal.command.DbMigrate : Successfully applied 1 migration to schema "PUBLIC", now at version v1 (execution time 00:00.006s)
2026-10-08T17:55:50.829+05:30 INFO 10940 --- [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-08T17:55:50.897+05:30 INFO 10940 --- [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-08T17:55:50.902+05:30 INFO 10940 --- [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:59a60d3a-9329-4255-85ca-9683b850ef31]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 59A60D3A-9329-4255-85CA-9683B850EF31/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-08T17:55:50.956+05:30 INFO 10940 --- [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-08T17:55:50.958+05:30 INFO 10940 --- [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-08T17:55:51.035+05:30 INFO 10940 --- [ main] .d.NoSubstitutionStillSilentlyUsesH2Test : Started NoSubstitutionStillSilentlyUsesH2Test in 0.949 seconds (process running for 9.717)
JDBC reports database product name: H2 (even with replace = Replace.NONE and no @ServiceConnection anywhere)
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 1.083 s -- in com.ankurm.datajpa.NoSubstitutionStillSilentlyUsesH2Test
[INFO] Running com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.081+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest]: ExplicitFlywayImportIsRedundantTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.135+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.139+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest]: ExplicitFlywayImportIsRedundantTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.195+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
flyway_schema_history real migration rows (rank >= 0): 1
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.226 s -- in com.ankurm.datajpa.ExplicitFlywayImportIsRedundantTest
2026-10-08T17:55:51.324+05:30 INFO 10940 --- [ main] org.testcontainers.DockerClientFactory : Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
2026-10-08T17:55:51.689+05:30 INFO 10940 --- [ main] .t.d.DockerMachineClientProviderStrategy : docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
2026-10-08T17:55:51.691+05:30 ERROR 10940 --- [ main] o.t.d.DockerClientProviderStrategy : Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
[INFO] Running com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.009 s -- in com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[INFO] Running com.ankurm.datajpa.FlywayRunsByDefaultTest
2026-10-08T17:55:51.702+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.FlywayRunsByDefaultTest]: FlywayRunsByDefaultTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.716+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.FlywayRunsByDefaultTest
2026-10-08T17:55:51.717+05:30 INFO 10940 --- [ main] t.c.s.AnnotationConfigContextLoaderUtils : Could not detect default configuration classes for test class [com.ankurm.datajpa.FlywayRunsByDefaultTest]: FlywayRunsByDefaultTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
2026-10-08T17:55:51.731+05:30 INFO 10940 --- [ main] .b.t.c.SpringBootTestContextBootstrapper : Found @SpringBootConfiguration com.ankurm.datajpa.DataJpaTestcontainersApplication for test class com.ankurm.datajpa.FlywayRunsByDefaultTest
flyway_schema_history row: version=1 description=create product success=true
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.079 s -- in com.ankurm.datajpa.FlywayRunsByDefaultTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 14.464 s
[INFO] Finished at: 2026-10-08T17:55:51+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,82 @@
$ javap -p org/testcontainers/postgresql/PostgreSQLContainer.class # testcontainers-postgresql-2.0.5.jar
Compiled from "PostgreSQLContainer.java"
public class org.testcontainers.postgresql.PostgreSQLContainer extends org.testcontainers.containers.JdbcDatabaseContainer<org.testcontainers.postgresql.PostgreSQLContainer> {
public static final java.lang.String NAME;
public static final java.lang.String IMAGE;
public static final java.lang.String DEFAULT_TAG;
private static final org.testcontainers.utility.DockerImageName DEFAULT_IMAGE_NAME;
private static final org.testcontainers.utility.DockerImageName PGVECTOR_IMAGE_NAME;
public static final java.lang.Integer POSTGRESQL_PORT;
static final java.lang.String DEFAULT_USER;
static final java.lang.String DEFAULT_PASSWORD;
private java.lang.String databaseName;
private java.lang.String username;
private java.lang.String password;
private static final java.lang.String FSYNC_OFF_OPTION;
public org.testcontainers.postgresql.PostgreSQLContainer(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer(org.testcontainers.utility.DockerImageName);
protected java.util.Set<java.lang.Integer> getLivenessCheckPorts();
protected void configure();
public java.lang.String getDriverClassName();
public java.lang.String getJdbcUrl();
public java.lang.String getDatabaseName();
public java.lang.String getUsername();
public java.lang.String getPassword();
public java.lang.String getTestQueryString();
public org.testcontainers.postgresql.PostgreSQLContainer withDatabaseName(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer withUsername(java.lang.String);
public org.testcontainers.postgresql.PostgreSQLContainer withPassword(java.lang.String);
protected void waitUntilContainerStarted();
public org.testcontainers.containers.JdbcDatabaseContainer withDatabaseName(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withPassword(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withUsername(java.lang.String);
static {};
}
$ javap -p org/testcontainers/containers/PostgreSQLContainer.class # SAME jar, deprecated shim
Compiled from "PostgreSQLContainer.java"
public class org.testcontainers.containers.PostgreSQLContainer<SELF extends org.testcontainers.containers.PostgreSQLContainer<SELF>> extends org.testcontainers.containers.JdbcDatabaseContainer<SELF> {
public static final java.lang.String NAME;
public static final java.lang.String IMAGE;
public static final java.lang.String DEFAULT_TAG;
private static final org.testcontainers.utility.DockerImageName DEFAULT_IMAGE_NAME;
private static final org.testcontainers.utility.DockerImageName PGVECTOR_IMAGE_NAME;
public static final java.lang.Integer POSTGRESQL_PORT;
static final java.lang.String DEFAULT_USER;
static final java.lang.String DEFAULT_PASSWORD;
private java.lang.String databaseName;
private java.lang.String username;
private java.lang.String password;
private static final java.lang.String FSYNC_OFF_OPTION;
public org.testcontainers.containers.PostgreSQLContainer();
public org.testcontainers.containers.PostgreSQLContainer(java.lang.String);
public org.testcontainers.containers.PostgreSQLContainer(org.testcontainers.utility.DockerImageName);
protected java.util.Set<java.lang.Integer> getLivenessCheckPorts();
protected void configure();
public java.lang.String getDriverClassName();
public java.lang.String getJdbcUrl();
public java.lang.String getDatabaseName();
public java.lang.String getUsername();
public java.lang.String getPassword();
public java.lang.String getTestQueryString();
public SELF withDatabaseName(java.lang.String);
public SELF withUsername(java.lang.String);
public SELF withPassword(java.lang.String);
protected void waitUntilContainerStarted();
public org.testcontainers.containers.JdbcDatabaseContainer withDatabaseName(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withPassword(java.lang.String);
public org.testcontainers.containers.JdbcDatabaseContainer withUsername(java.lang.String);
static {};
}
$ javap -v org/testcontainers/containers/PostgreSQLContainer.class | grep -i deprecated
#7 = Utf8 Ljava/lang/Deprecated;
#232 = Utf8 Deprecated
Deprecated: true
java.lang.Deprecated
Deprecated: true
$ unzip -l /root/.m2/repository/org/testcontainers/testcontainers-postgresql/2.0.5/testcontainers-postgresql-2.0.5.jar | grep PostgreSQLContainer
5533 2026-04-06 22:36 org/testcontainers/postgresql/PostgreSQLContainer.class
1745 2026-04-06 22:36 org/testcontainers/containers/PostgreSQLContainerProvider.class
6150 2026-04-06 22:36 org/testcontainers/containers/PostgreSQLContainer.class
@@ -0,0 +1,15 @@
$ javap -p org/springframework/boot/jdbc/testcontainers/JdbcContainerConnectionDetailsFactory.class # spring-boot-jdbc-4.1.1.jar
Compiled from "JdbcContainerConnectionDetailsFactory.java"
class org.springframework.boot.jdbc.testcontainers.JdbcContainerConnectionDetailsFactory extends org.springframework.boot.testcontainers.service.connection.ContainerConnectionDetailsFactory<org.testcontainers.containers.JdbcDatabaseContainer<?>, org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails> {
org.springframework.boot.jdbc.testcontainers.JdbcContainerConnectionDetailsFactory();
protected org.springframework.boot.jdbc.autoconfigure.JdbcConnectionDetails getContainerConnectionDetails(org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource<org.testcontainers.containers.JdbcDatabaseContainer<?>>);
protected org.springframework.boot.autoconfigure.service.connection.ConnectionDetails getContainerConnectionDetails(org.springframework.boot.testcontainers.service.connection.ContainerConnectionSource);
}
$ javap -p org/springframework/boot/testcontainers/service/connection/ServiceConnection.class # spring-boot-testcontainers-4.1.1.jar
Compiled from "ServiceConnection.java"
public interface org.springframework.boot.testcontainers.service.connection.ServiceConnection extends java.lang.annotation.Annotation {
public abstract java.lang.String value();
public abstract java.lang.String name();
public abstract java.lang.Class<? extends org.springframework.boot.autoconfigure.service.connection.ConnectionDetails>[] type();
}
@@ -0,0 +1,151 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
[INFO] Running com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest
17:20:10.044 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest]: ServiceConnectionWithoutGuardFailsToStartTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:20:10.211 [main] INFO org.testcontainers.images.PullPolicy -- Image pull policy will be performed by: DefaultPullPolicy()
17:20:10.213 [main] INFO org.testcontainers.utility.ImageNameSubstitutor -- Image name substitution will be performed by: DefaultImageNameSubstitutor (composite of 'ConfigurationFileImageNameSubstitutor' and 'PrefixingImageNameSubstitutor')
17:20:10.253 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
17:20:10.569 [main] INFO org.testcontainers.dockerclient.DockerMachineClientProviderStrategy -- docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
17:20:10.570 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
17:20:10.586 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest]: ServiceConnectionWithoutGuardFailsToStartTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
17:20:10.617 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0, Time elapsed: 0.877 s <<< FAILURE! -- in com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest
[ERROR] com.ankurm.datajpa.ServiceConnectionWithoutGuardFailsToStartTest -- Time elapsed: 0.877 s <<< ERROR!
org.testcontainers.containers.ContainerFetchException: Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1308)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createSource(ServiceConnectionContextCustomizerFactory.java:90)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$1(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:591)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:558)
at java.base/java.util.Spliterator.forEachRemaining(Spliterator.java:332)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:803)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$0(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.util.ReflectionUtils.doWithLocalFields(ReflectionUtils.java:691)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.collectSources(ServiceConnectionContextCustomizerFactory.java:62)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createContextCustomizer(ServiceConnectionContextCustomizerFactory.java:54)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.getContextCustomizers(AbstractTestContextBootstrapper.java:420)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:393)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:268)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:226)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:117)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.buildTestContext(SpringBootTestContextBootstrapper.java:102)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:142)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:126)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:466)
at org.springframework.test.context.junit.jupiter.SpringExtension.beforeAll(SpringExtension.java:209)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
Suppressed: org.testcontainers.containers.ContainerFetchException: Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1308)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createSource(ServiceConnectionContextCustomizerFactory.java:90)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$1(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:591)
at org.springframework.core.annotation.TypeMappedAnnotations$AggregatesSpliterator.tryAdvance(TypeMappedAnnotations.java:558)
at java.base/java.util.Spliterator.forEachRemaining(Spliterator.java:332)
at java.base/java.util.stream.ReferencePipeline$Head.forEach(ReferencePipeline.java:803)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.lambda$collectSources$0(ServiceConnectionContextCustomizerFactory.java:65)
at org.springframework.util.ReflectionUtils.doWithLocalFields(ReflectionUtils.java:691)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.collectSources(ServiceConnectionContextCustomizerFactory.java:62)
at org.springframework.boot.testcontainers.service.connection.ServiceConnectionContextCustomizerFactory.createContextCustomizer(ServiceConnectionContextCustomizerFactory.java:54)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.getContextCustomizers(AbstractTestContextBootstrapper.java:420)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:393)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildDefaultMergedContextConfiguration(AbstractTestContextBootstrapper.java:268)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildMergedContextConfiguration(AbstractTestContextBootstrapper.java:226)
at org.springframework.test.context.support.AbstractTestContextBootstrapper.buildTestContext(AbstractTestContextBootstrapper.java:117)
at org.springframework.boot.test.context.SpringBootTestContextBootstrapper.buildTestContext(SpringBootTestContextBootstrapper.java:102)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:142)
at org.springframework.test.context.TestContextManager.<init>(TestContextManager.java:126)
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328)
at org.springframework.test.context.junit.jupiter.SpringExtension.getTestContextManager(SpringExtension.java:466)
at org.springframework.test.context.junit.jupiter.SpringExtension.afterAll(SpringExtension.java:220)
... 1 more
Caused by: java.lang.IllegalStateException: Previous attempts to find a Docker environment failed. Will not retry. Please see logs and check configuration
at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:229)
at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:196)
at org.testcontainers.images.LocalImagesCache.get(LocalImagesCache.java:31)
at org.testcontainers.images.AbstractImagePullPolicy.shouldPull(AbstractImagePullPolicy.java:18)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:79)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:35)
at org.testcontainers.utility.LazyFuture.getResolvedValue(LazyFuture.java:20)
at org.testcontainers.utility.LazyFuture.get(LazyFuture.java:41)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1306)
... 22 more
Caused by: java.lang.IllegalStateException: Could not find a valid Docker environment. Please see logs and check configuration
at org.testcontainers.dockerclient.DockerClientProviderStrategy.lambda$getFirstValidStrategy$7(DockerClientProviderStrategy.java:274)
at java.base/java.util.Optional.orElseThrow(Optional.java:403)
at org.testcontainers.dockerclient.DockerClientProviderStrategy.getFirstValidStrategy(DockerClientProviderStrategy.java:265)
at org.testcontainers.DockerClientFactory.getOrInitializeStrategy(DockerClientFactory.java:154)
at org.testcontainers.DockerClientFactory.client(DockerClientFactory.java:196)
at org.testcontainers.images.LocalImagesCache.get(LocalImagesCache.java:31)
at org.testcontainers.images.AbstractImagePullPolicy.shouldPull(AbstractImagePullPolicy.java:18)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:79)
at org.testcontainers.images.RemoteDockerImage.resolve(RemoteDockerImage.java:35)
at org.testcontainers.utility.LazyFuture.getResolvedValue(LazyFuture.java:20)
at org.testcontainers.utility.LazyFuture.get(LazyFuture.java:41)
at org.testcontainers.containers.GenericContainer.getDockerImageName(GenericContainer.java:1306)
... 22 more
[INFO]
[INFO] Results:
[INFO]
[ERROR] Errors:
[ERROR] ServiceConnectionWithoutGuardFailsToStartTest ? ContainerFetch Can't get Docker image: RemoteDockerImage(imageName=postgres:17, imagePullPolicy=DefaultPullPolicy(), imageNameSubstitutor=org.testcontainers.utility.ImageNameSubstitutor$LogWrappedImageNameSubstitutor@27f1bbe0)
[INFO]
[ERROR] Tests run: 1, Failures: 0, Errors: 1, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD FAILURE
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 4.242 s
[INFO] Finished at: 2026-10-08T17:20:10+05:30
[INFO] ------------------------------------------------------------------------
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-surefire-plugin:3.5.6:test (default-test) on project data-jpa-testcontainers:
[ERROR]
[ERROR] See /home/claude/spring-boot-demo/data-jpa-testcontainers/target/surefire-reports for the individual test results.
[ERROR] See dump files (if any exist) [date].dump, [date]-jvmRun[N].dump and [date].dumpstream.
[ERROR] -> [Help 1]
[ERROR]
[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch.
[ERROR] Re-run Maven using the -X switch to enable full debug logging.
[ERROR]
[ERROR] For more information about the errors and possible solutions, please read the following articles:
[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoFailureException
@@ -0,0 +1,56 @@
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
WARNING: A terminally deprecated method in sun.misc.Unsafe has been called
WARNING: sun.misc.Unsafe::staticFieldBase has been called by com.google.inject.internal.aop.HiddenClassDefiner (file:/opt/maven/lib/guice-5.1.0-classes.jar)
WARNING: Please consider reporting this to the maintainers of class com.google.inject.internal.aop.HiddenClassDefiner
WARNING: sun.misc.Unsafe::staticFieldBase will be removed in a future release
[INFO] Scanning for projects...
[INFO]
[INFO] -----------------< com.ankurm:data-jpa-testcontainers >-----------------
[INFO] Building data-jpa-testcontainers 1.0.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- resources:3.5.0:resources (default-resources) @ data-jpa-testcontainers ---
[INFO] Copying 0 resource from src/main/resources to target/classes
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.15.0:compile (default-compile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- resources:3.5.0:testResources (default-testResources) @ data-jpa-testcontainers ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/data-jpa-testcontainers/src/test/resources
[INFO]
[INFO] --- compiler:3.15.0:testCompile (default-testCompile) @ data-jpa-testcontainers ---
[INFO] Nothing to compile - all classes are up to date.
[INFO]
[INFO] --- surefire:3.5.6:test (default-test) @ data-jpa-testcontainers ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
Picked up JAVA_TOOL_OPTIONS: -Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts -Djavax.net.ssl.trustStoreType=JKS -Dhttps.proxyHost=127.0.0.1 -Dhttps.proxyPort=34689 -Dhttp.nonProxyHosts=localhost|127.0.0.1|::1|127.*|0.*|::|169.254.*|api.anthropic.com|api-staging.anthropic.com|api-pr-preview.anthropic.com|mcp-proxy.anthropic.com|mcp-proxy-staging.anthropic.com|registry.npmjs.org|jsr.io|npm.jsr.io|pypi.org|files.pythonhosted.org|index.crates.io|proxy.golang.org|host.docker.internal|10.*|172.16.*|172.17.*|172.18.*|172.19.*|172.20.*|172.21.*|172.22.*|172.23.*|172.24.*|172.25.*|172.26.*|172.27.*|172.28.*|172.29.*|172.30.*|172.31.*|192.168.*|100.64.0.0/10|*.svc.cluster.local|*.svc.cluster.local -Djdk.http.auth.tunneling.disabledSchemes= -Djdk.http.auth.proxying.disabledSchemes=
17:20:33.981 [main] INFO org.testcontainers.DockerClientFactory -- Testcontainers version: 2.0.5
WARNING: A restricted method in java.lang.System has been called
WARNING: java.lang.System::load has been called by com.sun.jna.Native in an unnamed module (file:/root/.m2/repository/net/java/dev/jna/jna/5.18.1/jna-5.18.1.jar)
WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning for callers in this module
WARNING: Restricted methods will be blocked in a future release unless native access is enabled
17:20:34.343 [main] INFO org.testcontainers.dockerclient.DockerMachineClientProviderStrategy -- docker-machine executable was not found on PATH ([/opt/jdk-25/bin, /home/claude/.npm-global/bin, /nix/var/nix/profiles/default/bin, /root/.local/bin, /root/.cargo/bin, /usr/local/go/bin, /opt/node22/bin, /opt/maven/bin, /opt/gradle/bin, /opt/rbenv/bin, /root/.bun/bin, /usr/local/sbin, /usr/local/bin, /usr/sbin, /usr/bin, /sbin, /bin])
17:20:34.345 [main] ERROR org.testcontainers.dockerclient.DockerClientProviderStrategy -- Could not find a valid Docker environment. Please check configuration. Attempted configurations were:
UnixSocketClientProviderStrategy: failed with exception InvalidConfigurationException (Could not find unix domain socket). Root cause NoSuchFileException (/var/run/docker.sock)
DockerDesktopClientProviderStrategy: failed with exception NullPointerException (Cannot invoke "java.nio.file.Path.toString()" because the return value of "org.testcontainers.dockerclient.DockerDesktopClientProviderStrategy.getSocketPath()" is null)As no valid configuration was found, execution cannot continue.
See https://java.testcontainers.org/on_failure.html for more details.
[INFO] Running com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[WARNING] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 0.059 s -- in com.ankurm.datajpa.ServiceConnectionSkipsCleanlyTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 1
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 3.721 s
[INFO] Finished at: 2026-10-08T17:20:34+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,40 @@
$ javap -v org/springframework/boot/jdbc/test/autoconfigure/AutoConfigureJdbc.class
# unzipped from spring-boot-jdbc-test-4.1.1.jar
RuntimeVisibleAnnotations:
0: #10(#11=[e#12.#13])
java.lang.annotation.Target(
value=[Ljava/lang/annotation/ElementType;.TYPE]
)
1: #14(#11=e#15.#16)
java.lang.annotation.Retention(
value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME
)
2: #17()
java.lang.annotation.Documented
3: #18()
java.lang.annotation.Inherited
4: #19()
org.springframework.boot.autoconfigure.ImportAutoConfiguration
5: #20()
org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization
$ find . -iname '*DataSourceInitialization*' # inside spring-boot-jdbc-test-4.1.1.jar
./META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.jdbc.autoconfigure.DataSourceInitializationAutoConfiguration
# the SAME resource path, inside spring-boot-flyway-4.1.1.jar instead:
$ cat META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports
org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration
$ javap -v org/springframework/boot/flyway/autoconfigure/FlywayAutoConfiguration.class # unzipped from spring-boot-flyway-4.1.1.jar
#41 = Utf8 Lorg/springframework/boot/autoconfigure/condition/ConditionalOnClass;
#42 = Utf8 value
#43 = Utf8 Lorg/flywaydb/core/Flyway;
#44 = Utf8 Lorg/springframework/context/annotation/Conditional;
--
org.springframework.boot.autoconfigure.condition.ConditionalOnClass(
value=[class Lorg/flywaydb/core/Flyway;]
)
2: #44(#42=[c#45])
+149
View File
@@ -0,0 +1,149 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>data-jpa-testcontainers</artifactId>
<version>1.0.0</version>
<name>data-jpa-testcontainers</name>
<description>@DataJpaTest with Testcontainers @ServiceConnection on Spring Boot 4.1.1 / Testcontainers 2.0.5:
what the default embedded-database substitution actually does, what @ServiceConnection wires in its
place, and what each one looks like when something is missing.</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Flyway 12 split its database-specific parsers out of flyway-core (confirmed in the
db-migrations-flyway-liquibase module's findings). This repo's migration is run twice in
this article: once for real against H2 (no extra dependency needed, H2 support is built
into flyway-core) and once, in the real-Postgres test classes, against whatever
PostgreSQLContainer provides — which needs this dialect module on the classpath even
though that test class cannot start a container in this sandbox. -->
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<!-- flyway-core on the classpath is NOT enough to get FlywayAutoConfiguration — that class
lives in its own starter, a further instance of Boot 4's per-technology module split
(confirmed by comparing against the db-migrations-flyway-liquibase module, which needs the
exact same starter). This is what actually makes Flyway run under @DataJpaTest by default
in this module: @AutoConfigureJdbc's @AutoConfigureDataSourceInitialization meta-annotation
merges a META-INF/spring/...AutoConfigureDataSourceInitialization.imports file from every
jar that ships one, and spring-boot-flyway (pulled in by this starter) is one of them —
confirmed with javap and unzip, see FlywayRunsByDefaultTest's Javadoc. Test scope only:
this article's main app never runs Flyway itself outside the test slice. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @DataJpaTest itself: relocated out of spring-boot-test-autoconfigure in Boot 4.1 into its
own module. Confirmed by unzipping this jar: the only class in it is DataJpaTest plus its
bootstrapper and type-exclude filter — TestEntityManager and AutoConfigureTestDatabase are
NOT in here, they come in transitively from the two dependencies below. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-data-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Pulled in transitively by spring-boot-data-jpa-test above (spring-boot-jpa-test for
TestEntityManager, spring-boot-jdbc-test for AutoConfigureTestDatabase/TestDatabaseAutoConfiguration)
— listed explicitly here only so the module table in the README has something to point at;
removing these two lines changes nothing on the classpath. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jpa-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-jdbc-test</artifactId>
<scope>test</scope>
</dependency>
<!-- @ServiceConnection itself — unaffected by the Testcontainers 2.0 package moves below,
because it is typed against org.testcontainers.containers.JdbcDatabaseContainer, which did
not move. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Testcontainers 2.0.5. PostgreSQLContainer lives at org.testcontainers.postgresql in this
version; org.testcontainers.containers.PostgreSQLContainer still exists as a working
@Deprecated subclass, in this SAME jar (confirmed with `unzip -l`: both classes are inside
testcontainers-postgresql-2.0.5.jar, not split across two artifacts). -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-postgresql</artifactId>
<scope>test</scope>
</dependency>
<!-- The JUnit 5 extension's artifact was renamed from junit-jupiter to
testcontainers-junit-jupiter in 2.0; its package, org.testcontainers.junit.jupiter, did not
change. This version also ships a new @EnabledIfDockerAvailable condition, used below. -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>testcontainers-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>data-jpa-testcontainers</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!-- This class exists to demonstrate a real startup FAILURE on purpose: a container with
no guard against a missing Docker daemon. It is run individually with
`mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest` to capture that
failure for docs/output — excluded here so the module's default `mvn test` build
stays green. -->
<excludes>
<exclude>**/ServiceConnectionWithoutGuardFailsToStartTest.java</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,12 @@
package com.ankurm.datajpa;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DataJpaTestcontainersApplication {
public static void main(String[] args) {
SpringApplication.run(DataJpaTestcontainersApplication.class, args);
}
}
@@ -0,0 +1,49 @@
package com.ankurm.datajpa;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
/**
* A deliberately boring entity. The point of this module is not the domain model -- it is what
* backs the DataSource underneath it, which is why every field here is the simplest type that
* will do the job.
*
* See the "@DataJpaTest with Testcontainers @ServiceConnection" article on ankurm.com for the
* chapter this entity supports.
*/
@Entity
@Table(name = "product")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String sku;
private String name;
protected Product() {
// JPA
}
public Product(String sku, String name) {
this.sku = sku;
this.name = name;
}
public Long getId() {
return id;
}
public String getSku() {
return sku;
}
public String getName() {
return name;
}
}
@@ -0,0 +1,10 @@
package com.ankurm.datajpa;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ProductRepository extends JpaRepository<Product, Long> {
Optional<Product> findBySku(String sku);
}
@@ -0,0 +1,5 @@
create table product (
id bigint generated by default as identity primary key,
sku varchar(64) not null unique,
name varchar(255) not null
);
@@ -0,0 +1,58 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
/**
* No @ServiceConnection, no Testcontainers import anywhere in this file. Plain @DataJpaTest,
* exactly as most tutorials show it.
*
* What it actually runs against, and why the two tests below pass or fail in isolation from each
* other, is the subject of the article's second and third sections.
*/
@DataJpaTest
class DefaultReplacementTest {
@Autowired
private TestEntityManager entityManager;
@Autowired
private ProductRepository productRepository;
@Autowired
private DataSource dataSource;
@Test
void whateverIsBehindThisDataSourceIsNotPostgres() throws Exception {
String productName;
try (Connection connection = dataSource.getConnection()) {
productName = connection.getMetaData().getDatabaseProductName();
}
System.out.println("JDBC reports database product name: " + productName);
assertThat(productName).isNotEqualTo("PostgreSQL");
}
@Test
void insertedRowIsVisibleWithinThisTest() {
entityManager.persistAndFlush(new Product("SKU-1", "Widget"));
assertThat(productRepository.findBySku("SKU-1")).isPresent();
assertThat(productRepository.count()).isEqualTo(1);
}
@Test
void previousTestsRowIsGoneInThisOne() {
// If @DataJpaTest did not roll each test back, this would see the row the previous test
// method inserted. It does not -- each test method gets its own transaction, rolled back
// when the method returns, regardless of test execution order.
assertThat(productRepository.count()).isZero();
assertThat(productRepository.findBySku("SKU-1")).isEmpty();
}
}
@@ -0,0 +1,59 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.flyway.autoconfigure.FlywayAutoConfiguration;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* Older advice for this exact situation (including an earlier draft of this module, and more
* than one blog post this article will not link to) says to add
* {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)} yourself to get Flyway running
* inside a {@code @DataJpaTest}. As {@link FlywayRunsByDefaultTest} now shows, that is no longer
* necessary on Spring Boot 4.1 -- the import already happens on its own, by way of
* {@code @AutoConfigureJdbc}'s own {@code @AutoConfigureDataSourceInitialization} meta-annotation.
*
* <p>Adding the explicit import anyway is not harmful. {@code @ImportAutoConfiguration} is
* backed by ordinary Spring auto-configuration machinery: the same fully-qualified class name,
* however many times it is reached (once through the transitive meta-annotation walk, once
* through this class's own explicit annotation), is only ever registered once. This test proves
* that directly rather than asserting it from the mechanism alone: exactly one {@code flyway}
* bean, and exactly one applied migration -- not two -- with the redundant import sitting right
* there on the class.
*/
@DataJpaTest
@ImportAutoConfiguration(FlywayAutoConfiguration.class)
class ExplicitFlywayImportIsRedundantTest {
@Autowired
private DataSource dataSource;
@Autowired
private ApplicationContext ctx;
@Test
void redundantImportStillProducesExactlyOneFlywayBean() {
assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).hasSize(1);
}
@Test
void redundantImportDoesNotReapplyTheMigration() throws Exception {
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement()) {
var rs = statement.executeQuery(
"select count(*) from \"flyway_schema_history\" where \"installed_rank\" >= 0");
assertThat(rs.next()).isTrue();
int realMigrationRows = rs.getInt(1);
System.out.println("flyway_schema_history real migration rows (rank >= 0): " + realMigrationRows);
assertThat(realMigrationRows).isEqualTo(1);
}
}
}
@@ -0,0 +1,92 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.context.ApplicationContext;
import javax.sql.DataSource;
import java.sql.Connection;
import java.sql.ResultSet;
import java.sql.Statement;
import static org.assertj.core.api.Assertions.assertThat;
/**
* This module has a real Flyway migration, {@code V1__create_product.sql}, and flyway-core
* (plus the test-scoped {@code spring-boot-starter-flyway} -- see the module README for why that
* second one matters) is on the classpath. A plain {@code @DataJpaTest}, with no extra
* annotation anywhere on this class, runs that migration by default.
*
* <p>An earlier draft of this module asserted the opposite, on the strength of reading
* {@code @DataJpaTest}'s four documented meta-annotations ({@code @AutoConfigureDataJpa},
* {@code @AutoConfigureJdbc}, {@code @AutoConfigureTestDatabase},
* {@code @AutoConfigureTestEntityManager}) and finding that none of their four
* {@code .imports} manifests mentions Flyway or Liquibase. That reading is correct and
* incomplete: {@code AutoConfigureJdbc.class} is itself meta-annotated with a fifth annotation,
* {@code @AutoConfigureDataSourceInitialization} (new in Boot 4.0; confirmed with
* {@code javap -v org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureJdbc} against
* {@code spring-boot-jdbc-test-4.1.1.jar}), and Spring's own
* {@code ImportAutoConfigurationImportSelector} walks the entire meta-annotation tree
* transitively -- every annotation, and every meta-annotation on those -- collecting every class
* in that tree that is itself annotated with {@code @ImportAutoConfiguration}. That fifth
* annotation is one of them.
*
* <p>{@code @AutoConfigureDataSourceInitialization}'s own {@code .imports} resource is loaded via
* {@code ClassLoader.getResources()}, which returns every file at that path across every jar on
* the classpath and merges them -- not a single-file lookup. Three jars ship a file at the exact
* same resource path
* ({@code META-INF/spring/org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureDataSourceInitialization.imports}):
* {@code spring-boot-jdbc-test} contributes {@code DataSourceInitializationAutoConfiguration},
* {@code spring-boot-flyway} contributes {@code FlywayAutoConfiguration}, and
* {@code spring-boot-liquibase} contributes {@code LiquibaseAutoConfiguration}. Confirmed by
* unzipping both jars actually on this classpath and reading the file; the Liquibase side was
* not independently re-run here (no Liquibase module in this repo) but the same mechanism applies
* to it by construction. {@code FlywayAutoConfiguration} is still gated by the ordinary
* {@code @ConditionalOnClass(Flyway.class)} -- it only activates once flyway-core is genuinely on
* the classpath, which is the one part of the old draft's observation that was never wrong, just
* misattributed to the wrong mechanism.
*
* <p>See {@link ExplicitFlywayImportIsRedundantTest} for what this means for the (now
* unnecessary, but harmless) advice to {@code @ImportAutoConfiguration(FlywayAutoConfiguration.class)}
* yourself.
*/
@DataJpaTest
class FlywayRunsByDefaultTest {
@Autowired
private DataSource dataSource;
@Autowired
private ApplicationContext ctx;
@Test
void flywayAutoConfigurationBeansArePresentWithNoExplicitImportAnywhere() {
assertThat(ctx.getBeanNamesForType(org.flywaydb.core.Flyway.class)).isNotEmpty();
assertThat(ctx.containsBean("flywayInitializer")).isTrue();
}
@Test
void theMigrationActuallyRanAgainstTheSubstitutedDatabase() throws Exception {
// Flyway's own history table always carries a bookkeeping row at installed_rank = -1
// ("<< Flyway Schema History table created >>") ahead of the real migration rows, and
// both the table name and every column name are stored quoted and lowercase -- H2's
// default unquoted-identifier folding (to uppercase) does not apply to any of them.
try (Connection connection = dataSource.getConnection();
Statement statement = connection.createStatement();
ResultSet rs = statement.executeQuery(
"select \"installed_rank\", \"version\", \"description\", \"success\" "
+ "from \"flyway_schema_history\" where \"installed_rank\" = 1")) {
assertThat(rs.next()).isTrue();
String version = rs.getString(2);
String description = rs.getString(3);
boolean success = rs.getBoolean(4);
System.out.println("flyway_schema_history row: version=" + version
+ " description=" + description + " success=" + success);
assertThat(version).isEqualTo("1");
assertThat(description).isEqualTo("create product");
assertThat(success).isTrue();
assertThat(rs.next()).isFalse();
}
}
}
@@ -0,0 +1,51 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* The actually-surprising half of this trap. The intuition is that
* {@code @AutoConfigureTestDatabase(replace = Replace.NONE)} turns off the embedded substitution,
* so without a real connection configured anywhere this test should fail to start -- that was this
* class's original name and premise, until running it against this sandbox's lack of Docker proved
* that assumption wrong.
*
* What {@code replace = Replace.NONE} actually turns off is narrower: only
* {@code TestDatabaseAutoConfiguration}'s own bean-post-processing step, the one that replaces an
* already-configured production DataSource bean. It does nothing to the ordinary
* {@code DataSourceAutoConfiguration}, which auto-provisions an embedded database whenever no
* {@code spring.datasource.url} is set and an embedded driver is on the classpath -- completely
* independent machinery, with no awareness of the test slice at all. There is no
* {@code @ServiceConnection} in this class, and no connection properties anywhere. Replace.NONE is
* set. It still silently gets H2, with no warning that anything was substituted, because nothing
* Replace.NONE knows about was. The real log line is in
* {@code docs/output/00-full-test-run.txt}: a HikariDataSource backed by {@code jdbc:h2:mem:...},
* never the "Replacing 'dataSource' DataSource bean with embedded version" line that
* {@link DefaultReplacementTest} produces.
*/
@DataJpaTest
@AutoConfigureTestDatabase(replace = Replace.NONE)
class NoSubstitutionStillSilentlyUsesH2Test {
@Autowired
private DataSource dataSource;
@Test
void replaceNoneIsNotTheSameAsARealConnection() throws Exception {
String productName;
try (Connection connection = dataSource.getConnection()) {
productName = connection.getMetaData().getDatabaseProductName();
}
System.out.println("JDBC reports database product name: " + productName
+ " (even with replace = Replace.NONE and no @ServiceConnection anywhere)");
assertThat(productName).isEqualTo("H2");
}
}
@@ -0,0 +1,57 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.EnabledIfDockerAvailable;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import javax.sql.DataSource;
import java.sql.Connection;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* This is the article's intended, finished shape: a real Postgres container wired in as the
* DataSource for a @DataJpaTest slice, with the default embedded substitution turned off so the
* real connection is actually used.
*
* {@code @EnabledIfDockerAvailable} is new in Testcontainers 2.0's JUnit 5 extension (confirmed by
* `unzip -l` against the 2.0.5 artifact; it does not exist in 1.x). Without it, this class would
* try to start the container and fail loudly the moment Docker is not reachable -- see
* {@link ServiceConnectionWithoutGuardFailsToStartTest} for exactly what that looks like. With it,
* JUnit reports the whole class as cleanly SKIPPED instead, which is what actually happened the one
* time this class ran for this article: no Docker daemon in the build sandbox, captured in
* {@code docs/output/05-service-connection-skips-cleanly.txt}.
*/
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
@EnabledIfDockerAvailable
class ServiceConnectionSkipsCleanlyTest {
// Unlike the deprecated org.testcontainers.containers.PostgreSQLContainer<SELF extends ...>,
// this new org.testcontainers.postgresql.PostgreSQLContainer is not itself generic (confirmed
// with javap) -- it extends JdbcDatabaseContainer<PostgreSQLContainer> directly. Code ported
// from the old import that wrote `PostgreSQLContainer<?>` needs that type parameter dropped.
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Autowired
private DataSource dataSource;
@Test
void realDatabaseProductNameIsPostgres() throws Exception {
try (Connection connection = dataSource.getConnection()) {
String productName = connection.getMetaData().getDatabaseProductName();
System.out.println("JDBC reports database product name: " + productName);
assertThat(productName).isEqualTo("PostgreSQL");
}
}
}
@@ -0,0 +1,39 @@
package com.ankurm.datajpa;
import org.junit.jupiter.api.Test;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.postgresql.PostgreSQLContainer;
import static org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase.Replace;
/**
* Identical to {@link ServiceConnectionSkipsCleanlyTest} except for one missing annotation:
* no {@code @EnabledIfDockerAvailable}. NOT part of this module's default `mvn test` run -- see
* the surefire exclude in pom.xml. Run it on its own with:
*
* <pre>mvn test -Dtest=ServiceConnectionWithoutGuardFailsToStartTest</pre>
*
* Without the guard, the Testcontainers JUnit extension tries to start the container regardless of
* what @AutoConfigureTestDatabase says, and that start call is what actually fails. The captured
* failure -- including whichever of Spring Boot's own
* {@code DockerEnvironmentNotFoundFailureAnalyzer} or the raw Testcontainers exception came through
* -- is in {@code docs/output/04-service-connection-without-guard-fails.txt}.
*/
@DataJpaTest
@Testcontainers
@AutoConfigureTestDatabase(replace = Replace.NONE)
class ServiceConnectionWithoutGuardFailsToStartTest {
@Container
@ServiceConnection
static PostgreSQLContainer postgres = new PostgreSQLContainer("postgres:17");
@Test
void neverReached() {
// The test body is irrelevant -- container startup fails before this runs.
}
}
+2
View File
@@ -0,0 +1,2 @@
target/
cap/app.log
+25
View File
@@ -0,0 +1,25 @@
# file-transfer
Companion code for **[File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/)**.
Spring Boot 4.1.1, Spring Framework 7.0.9, Tomcat 11.0.24, JDK 25, AWS SDK for Java 2.56.0. No Docker needed.
| Area | Code | What the run shows |
|---|---|---|
| Multipart limits | `UploadController`, `UploadErrors`, `MultipartLimitsTest` | the 1 MB / 2 MB limits are enforced by Tomcat, so `MockMvc` accepts a file the real server rejects; the rejected-upload race (`cap/01-limits.txt`) |
| Filenames | `FileStore`, `FilenameAndDownloadTest` | `getOriginalFilename()` is whatever the client sent, including `../../`; the file is stored under a UUID (`cap/02-filename.txt`) |
| Download | `DownloadController` | `Resource` gives `Range`/206/416 for free; `Content-Disposition` encoding (`cap/03-download.txt`) |
| Presigned URLs | `S3PresignService`, `PresignController`, `FakeS3`, `PresignedUrlTest` | what is and is not covered by the signature; expiry; reuse (`cap/04-presign.txt`) |
| Streaming | `StreamController`, `scripts/run-all.sh` | 200 MB download and a 150 MB upload under `-Xmx64m` (`cap/05-streaming.txt`, `cap/06-big-upload.txt`) |
```bash
./scripts/run-all.sh # tests, then the -Xmx64m experiments; rewrites cap/*.txt
```
## What was and was not run
- The presigned URLs are produced by the real AWS SDK presigner and checked by `FakeS3`, a small in-process server
that recomputes the AWS Signature V4 query signature from the published algorithm. That proves the URLs are consistent
with the algorithm as documented. It does **not** prove a real S3 or MinIO server accepts them: this environment has
no Docker and no S3-compatible server, so nothing here was run against one.
- `app.s3.access-key` and `app.s3.secret-key` are the example keys from the AWS documentation. They are not credentials.
+6
View File
@@ -0,0 +1,6 @@
limits: max-file-size=1MB, max-request-size=2MB
real HTTP, one 500 KB file -> status=201 body={"id":"d8d0b4b8-d530-42aa-b005-9e1b747449a7","originalFilename":"a.txt","displayName":"a.txt","size":512000,"declaredContentType":"text/plai...
real HTTP, one 1.5 MB file -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, 3 x 900 KB in one request -> status=413 body={"detail":"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).","instance":"/files",...
real HTTP, one 10 MB file, 10 attempts -> 7 x status=413, 3 x connection error
MockMvc, the same 1.5 MB file -> status=201
+4
View File
@@ -0,0 +1,4 @@
client sent filename=../../etc/cron.d/evil.txt Content-Type=image/png
server response: {"id":"4980219c-b92a-46d4-a5ab-b5d7636fb7f0","originalFilename":"../../etc/cron.d/evil.txt","displayName":"evil.txt","size":5,"declaredContentType":"image/png"}
root.resolve(originalFilename).normalize() stays inside the storage dir: false
client sent a Windows-style path; server response: {"id":"d436e57e-6855-4fff-b5ca-b9dd0fec25c2","originalFilename":"..\\..\\windows\\system32\\evil.dll","displayName":"evil.dll","size":1,"declaredContentType":"text/plain"}
+12
View File
@@ -0,0 +1,12 @@
GET /files/{id}
status=200 body=ABCDEFGHIJKLMNOPQRSTUVWXYZ
content-type: application/octet-stream
content-length: 26
content-disposition: attachment; filename="resume 2026.txt"; filename*=UTF-8''r%C3%A9sum%C3%A9%202026.txt
accept-ranges: bytes
x-content-type-options: nosniff
GET /files/{id} with Range: bytes=10-19
status=206 body=KLMNOPQRST
content-range: bytes 10-19/26
GET /files/{id} with Range: bytes=500-
status=416 content-range: bytes */26
+18
View File
@@ -0,0 +1,18 @@
PUT a URL signed for 1 second, sent after 2.2 seconds -> status=403 <Error><Code>AccessDenied</Code></Error>
POST /presign/upload {"contentType":"image/png"} -> status=200
method=PUT expiresInSeconds=300 headers={"content-type":"image/png"}
url path: http://<fake-s3>/uploads/4dfdf0af-141a-4d1b-9d35-3609a9385791
query: X-Amz-Algorithm=AWS4-HMAC-SHA256
query: X-Amz-Date=20261017T154013Z
query: X-Amz-SignedHeaders=content-type%3Bhost
query: X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20261017%2Fus-east-1%2Fs3%2Faws4_request
query: X-Amz-Expires=300
query: X-Amz-Signature=4e5c56c29980cf521e477119788a0616c392deac4686df7259957af9f1445203
PUT with the signed Content-Type (image/png) -> status=200
PUT the same URL again (presigned URLs are reusable) -> status=200
PUT with a different Content-Type (text/html) -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT with no Content-Type header -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after changing the last character of the key -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
PUT after raising X-Amz-Expires to 7 days -> status=403 <Error><Code>SignatureDoesNotMatch</Code></Error>
GET presigned URL -> status=200 bytes match upload=true content-disposition=attachment; filename="holiday.png"
disallowed type: POST /presign/upload text/html -> status=400
+4
View File
@@ -0,0 +1,4 @@
JVM started with -Xmx64m
GET /buffered/200 (200 MB) -> status=500 downloaded=108 bytes in 0.273337s
GET /stream/200 (200 MB) -> status=200 downloaded=209715200 bytes in 0.337059s
GET /stream/1 afterwards -> status=200
+3
View File
@@ -0,0 +1,3 @@
JVM started with -Xmx64m, multipart limits removed, uploading a 150 MB file
multipart POST /files -> status=201 sent=157286616 bytes in 1.518657s
raw POST /files/raw -> status=201 sent=157286400 bytes in 1.850810s
+67
View File
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>file-transfer</artifactId>
<version>1.0.0</version>
<name>file-transfer</name>
<description>File upload and download in Spring Boot 4.1: multipart limits, filename handling, Range
downloads, StreamingResponseBody under a 64 MB heap, and S3 presigned URLs checked against a local
SigV4 verifier.</description>
<packaging>jar</packaging>
<properties>
<java.version>25</java.version>
<awssdk.version>2.56.0</awssdk.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>bom</artifactId>
<version>${awssdk.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Only the presigner is used: it signs locally and never opens a connection. -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Runs the whole module: the tests (which write cap/01..04), then the -Xmx64m streaming experiment (cap/05..06).
# No Docker needed. Takes a couple of minutes.
set -euo pipefail
cd "$(dirname "$0")/.."
mkdir -p cap
mvn -q -B test
mvn -q -B package -DskipTests
JAR=$(ls target/file-transfer-*.jar | grep -v original | head -1)
run_app() { # $1 = extra args; sets APP_PID
java -Xmx64m -jar "$JAR" --server.port=18080 $1 > cap/app.log 2>&1 &
APP_PID=$!
for _ in $(seq 1 60); do curl -s -o /dev/null http://localhost:18080/stream/1 && break; sleep 0.5; done
}
OUT=cap/05-streaming.txt; : > "$OUT"
run_app ""
echo "JVM started with -Xmx64m" >> "$OUT"
for kind in buffered stream; do
R=$(curl -s -o /dev/null -w "status=%{http_code} downloaded=%{size_download} bytes in %{time_total}s" http://localhost:18080/$kind/200 || true)
echo "GET /$kind/200 (200 MB) -> $R" >> "$OUT"
done
echo "GET /stream/1 afterwards -> $(curl -s -o /dev/null -w 'status=%{http_code}' http://localhost:18080/stream/1)" >> "$OUT"
kill $APP_PID; wait $APP_PID 2>/dev/null || true
OUT=cap/06-big-upload.txt; : > "$OUT"
head -c 157286400 /dev/zero > /tmp/ft-150mb.bin
run_app "--spring.servlet.multipart.max-file-size=-1 --spring.servlet.multipart.max-request-size=-1"
echo "JVM started with -Xmx64m, multipart limits removed, uploading a 150 MB file" >> "$OUT"
R=$(curl -s -o /dev/null -w "status=%{http_code} sent=%{size_upload} bytes in %{time_total}s" -F "file=@/tmp/ft-150mb.bin;type=application/octet-stream" http://localhost:18080/files || true)
echo "multipart POST /files -> $R" >> "$OUT"
R=$(curl -s -o /dev/null -w "status=%{http_code} sent=%{size_upload} bytes in %{time_total}s" -H "X-Filename: big.bin" --data-binary @/tmp/ft-150mb.bin -H "Content-Type: application/octet-stream" http://localhost:18080/files/raw || true)
echo "raw POST /files/raw -> $R" >> "$OUT"
kill $APP_PID; wait $APP_PID 2>/dev/null || true
rm -f /tmp/ft-150mb.bin
@@ -0,0 +1,42 @@
package com.ankurm.filetransfer;
import java.nio.charset.StandardCharsets;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class DownloadController {
private final FileStore store;
public DownloadController(FileStore store) {
this.store = store;
}
/**
* Returning a Resource gets Range support for free: Spring answers "Range: bytes=..." with 206 Partial Content.
* The content type is fixed to octet-stream on purpose: echoing back the type the client declared at upload
* would let an uploader make the browser render their file as HTML.
*/
@GetMapping("/files/{id}")
public ResponseEntity<Resource> download(@PathVariable String id) {
FileStore.StoredFile f = store.find(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no such file"));
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
ContentDisposition.attachment().filename(f.displayName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new FileSystemResource(f.path()));
}
}
@@ -0,0 +1,72 @@
package com.ankurm.filetransfer;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
/**
* Stores uploads on local disk under a server-generated id. The client's filename is never used as a path:
* it is kept only as display metadata, after stripping any directory part.
*/
@Service
public class FileStore {
public record StoredFile(String id, String originalFilename, String displayName, long size,
String declaredContentType, Path path) { }
private final Path root;
private final Map<String, StoredFile> files = new ConcurrentHashMap<>();
public FileStore(@Value("${app.storage-dir:}") String dir) throws IOException {
this.root = (dir.isBlank() ? Files.createTempDirectory("file-transfer") : Path.of(dir))
.toAbsolutePath().normalize();
Files.createDirectories(root);
}
public Path root() {
return root;
}
public StoredFile save(MultipartFile file) throws IOException {
String id = UUID.randomUUID().toString();
Path target = root.resolve(id);
file.transferTo(target);
return remember(id, file.getOriginalFilename(), Files.size(target), file.getContentType(), target);
}
public StoredFile saveStream(InputStream in, String name) throws IOException {
String id = UUID.randomUUID().toString();
Path target = root.resolve(id);
long size = Files.copy(in, target);
return remember(id, name, size, null, target);
}
public Optional<StoredFile> find(String id) {
return Optional.ofNullable(files.get(id));
}
private StoredFile remember(String id, String original, long size, String contentType, Path path) {
StoredFile stored = new StoredFile(id, original, displayName(original), size, contentType, path);
files.put(id, stored);
return stored;
}
/** Last path segment only, control characters removed. Handles both / and \ separators. */
static String displayName(String raw) {
if (raw == null || raw.isBlank()) {
return "unnamed";
}
String name = raw.replace('\\', '/');
name = name.substring(name.lastIndexOf('/') + 1).replaceAll("\\p{Cntrl}", "");
return name.isBlank() ? "unnamed" : name;
}
}
@@ -0,0 +1,12 @@
package com.ankurm.filetransfer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FileTransferApplication {
public static void main(String[] args) {
SpringApplication.run(FileTransferApplication.class, args);
}
}
@@ -0,0 +1,42 @@
package com.ankurm.filetransfer;
import java.time.Duration;
import java.util.Map;
import java.util.Set;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class PresignController {
public record UploadRequest(String contentType) { }
private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg", "application/pdf");
private static final Duration TTL = Duration.ofMinutes(5);
private final S3PresignService presign;
public PresignController(S3PresignService presign) {
this.presign = presign;
}
@PostMapping("/presign/upload")
public S3PresignService.Upload upload(@RequestBody UploadRequest request) {
if (request.contentType() == null || !ALLOWED.contains(request.contentType())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "content type not allowed");
}
return presign.forUpload(request.contentType(), TTL);
}
@GetMapping("/presign/download/{key}")
public Map<String, String> download(@PathVariable String key, @RequestParam(defaultValue = "download.bin") String filename) {
return Map.of("url", presign.forDownload(key, filename, TTL));
}
}
@@ -0,0 +1,68 @@
package com.ankurm.filetransfer;
import java.net.URI;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Configuration;
import software.amazon.awssdk.services.s3.model.GetObjectRequest;
import software.amazon.awssdk.services.s3.model.PutObjectRequest;
import software.amazon.awssdk.services.s3.presigner.S3Presigner;
import software.amazon.awssdk.services.s3.presigner.model.PresignedGetObjectRequest;
import software.amazon.awssdk.services.s3.presigner.model.PresignedPutObjectRequest;
/** Signs S3 / MinIO URLs locally. Nothing here talks to the network. */
@Service
public class S3PresignService implements AutoCloseable {
public record Upload(String key, String url, String method, Map<String, String> headers, long expiresInSeconds) { }
private final S3Presigner presigner;
private final String bucket;
public S3PresignService(@Value("${app.s3.endpoint}") String endpoint, @Value("${app.s3.bucket}") String bucket,
@Value("${app.s3.access-key}") String accessKey, @Value("${app.s3.secret-key}") String secretKey) {
this.bucket = bucket;
this.presigner = S3Presigner.builder()
.region(Region.US_EAST_1)
.endpointOverride(URI.create(endpoint))
.credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey)))
.serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build())
.build();
}
/** The object key is chosen here, never by the client. The content type is part of the signature. */
public Upload forUpload(String contentType, Duration ttl) {
String key = UUID.randomUUID().toString();
PutObjectRequest put = PutObjectRequest.builder().bucket(bucket).key(key).contentType(contentType).build();
PresignedPutObjectRequest signed = presigner.presignPutObject(r -> r.signatureDuration(ttl).putObjectRequest(put));
Map<String, String> headers = new LinkedHashMap<>();
for (Map.Entry<String, List<String>> h : signed.signedHeaders().entrySet()) {
if (!h.getKey().equalsIgnoreCase("host")) {
headers.put(h.getKey(), String.join(",", h.getValue()));
}
}
return new Upload(key, signed.url().toString(), "PUT", headers, ttl.toSeconds());
}
public String forDownload(String key, String filename, Duration ttl) {
GetObjectRequest get = GetObjectRequest.builder().bucket(bucket).key(key)
.responseContentDisposition("attachment; filename=\"" + filename.replace("\"", "") + "\"").build();
PresignedGetObjectRequest signed = presigner.presignGetObject(r -> r.signatureDuration(ttl).getObjectRequest(get));
return signed.url().toString();
}
@Override
public void close() {
presigner.close();
}
}
@@ -0,0 +1,39 @@
package com.ankurm.filetransfer;
import java.util.Arrays;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
/** The same N megabytes of data, sent two ways. Run both under a small heap and compare. */
@RestController
public class StreamController {
private static final int CHUNK = 64 * 1024;
/** Streams in 64 KB chunks: memory use does not depend on the size of the file. */
@GetMapping("/stream/{mb}")
public ResponseEntity<StreamingResponseBody> stream(@PathVariable int mb) {
long total = (long) mb * 1024 * 1024;
StreamingResponseBody body = out -> {
byte[] chunk = new byte[CHUNK];
Arrays.fill(chunk, (byte) 'x');
for (long written = 0; written < total; written += CHUNK) {
out.write(chunk, 0, (int) Math.min(CHUNK, total - written));
}
};
return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).contentLength(total).body(body);
}
/** Builds the whole thing in memory first: the heap must hold the entire file. */
@GetMapping(path = "/buffered/{mb}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public byte[] buffered(@PathVariable int mb) {
byte[] all = new byte[mb * 1024 * 1024];
Arrays.fill(all, (byte) 'x');
return all;
}
}
@@ -0,0 +1,49 @@
package com.ankurm.filetransfer;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.server.ResponseStatusException;
@RestController
public class UploadController {
public record UploadResult(String id, String originalFilename, String displayName, long size,
String declaredContentType) { }
private final FileStore store;
public UploadController(FileStore store) {
this.store = store;
}
/** Multipart upload: the container parses the body and spills big parts to a temp file. */
@PostMapping(path = "/files", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<UploadResult> upload(@RequestParam("file") MultipartFile file) throws IOException {
if (file.isEmpty()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "empty file");
}
return created(store.save(file));
}
/** Raw upload: the request body is the file. No multipart parsing, so the multipart limits do not apply. */
@PostMapping("/files/raw")
public ResponseEntity<UploadResult> raw(InputStream body,
@RequestHeader(value = "X-Filename", defaultValue = "upload.bin") String name) throws IOException {
return created(store.saveStream(body, name));
}
private static ResponseEntity<UploadResult> created(FileStore.StoredFile s) {
return ResponseEntity.created(URI.create("/files/" + s.id()))
.body(new UploadResult(s.id(), s.originalFilename(), s.displayName(), s.size(), s.declaredContentType()));
}
}
@@ -0,0 +1,20 @@
package com.ankurm.filetransfer;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
/** Turns the container's size-limit failure into a JSON problem instead of an empty 413. */
@RestControllerAdvice
public class UploadErrors {
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ProblemDetail tooLarge(MaxUploadSizeExceededException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONTENT_TOO_LARGE,
"The upload is larger than the configured limit (spring.servlet.multipart.max-file-size / max-request-size).");
pd.setTitle("Upload too large");
return pd;
}
}
@@ -0,0 +1,13 @@
# Per-file and per-request limits. Enforced by the servlet container (Tomcat), not by Spring MVC.
spring.servlet.multipart.max-file-size=1MB
spring.servlet.multipart.max-request-size=2MB
# Where uploads land. Blank = a fresh temp directory per run.
app.storage-dir=
# S3 / MinIO settings for the presigner. These are the example keys from the AWS documentation,
# not real credentials. Override all four for a real bucket.
app.s3.endpoint=http://localhost:9000
app.s3.bucket=uploads
app.s3.access-key=AKIAIOSFODNN7EXAMPLE
app.s3.secret-key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
@@ -0,0 +1,89 @@
package com.ankurm.filetransfer;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.ArrayList;
import java.util.List;
/** Test helpers: write the transcript to cap/, and build multipart bodies by hand so the test controls every byte. */
final class Cap {
static final Path DIR = Path.of("cap");
static final HttpClient HTTP = HttpClient.newHttpClient();
private Cap() { }
static void reset(String file) {
try {
Files.createDirectories(DIR);
Files.deleteIfExists(DIR.resolve(file));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
static void say(String file, String line) {
System.out.println(line);
try {
Files.writeString(DIR.resolve(file), line + System.lineSeparator(),
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
record Part(String field, String filename, String contentType, byte[] content) { }
static byte[] multipart(String boundary, List<Part> parts) {
List<byte[]> chunks = new ArrayList<>();
for (Part p : parts) {
String head = "--" + boundary + "\r\n"
+ "Content-Disposition: form-data; name=\"" + p.field() + "\"; filename=\"" + p.filename() + "\"\r\n"
+ "Content-Type: " + p.contentType() + "\r\n\r\n";
chunks.add(head.getBytes(StandardCharsets.UTF_8));
chunks.add(p.content());
chunks.add("\r\n".getBytes(StandardCharsets.UTF_8));
}
chunks.add(("--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
int len = chunks.stream().mapToInt(c -> c.length).sum();
byte[] all = new byte[len];
int pos = 0;
for (byte[] c : chunks) {
System.arraycopy(c, 0, all, pos, c.length);
pos += c.length;
}
return all;
}
/** POST the parts to /files; returns "status=NNN body=..." or the exception if the connection broke. */
static String postMultipart(String base, List<Part> parts) {
String boundary = "----ft" + System.nanoTime();
HttpRequest req = HttpRequest.newBuilder(URI.create(base + "/files"))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(multipart(boundary, parts))).build();
try {
HttpResponse<String> r = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
String body = r.body().replaceAll("\\s+", " ");
return "status=" + r.statusCode() + " body=" + (body.length() > 140 ? body.substring(0, 140) + "..." : body);
} catch (IOException e) {
return "no response, " + e.getClass().getSimpleName() + ": " + e.getMessage();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return "interrupted";
}
}
static byte[] bytes(int n) {
byte[] b = new byte[n];
java.util.Arrays.fill(b, (byte) 'a');
return b;
}
}
@@ -0,0 +1,164 @@
package com.ankurm.filetransfer;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
/**
* A tiny stand-in for S3 / MinIO that accepts PUT and GET on /{bucket}/{key} and checks the AWS Signature V4
* query-string signature, following the published algorithm. It exists because this build environment has no
* Docker. It proves the presigned URLs are self-consistent with the algorithm as documented; it does not prove
* that a real S3 or MinIO server would accept them.
*/
final class FakeS3 implements AutoCloseable {
record Obj(byte[] data, String contentType) { }
final Map<String, Obj> objects = new ConcurrentHashMap<>();
private final HttpServer server;
private final String secretKey;
FakeS3(String secretKey) throws IOException {
this.secretKey = secretKey;
this.server = HttpServer.create(new InetSocketAddress("localhost", 0), 0);
this.server.createContext("/", this::handle);
this.server.start();
}
String endpoint() {
return "http://localhost:" + server.getAddress().getPort();
}
@Override
public void close() {
server.stop(0);
}
private void handle(HttpExchange ex) throws IOException {
try {
String error = verify(ex);
if (error != null) {
String[] parts = error.split("\\|", 2);
reply(ex, 403, "<Error><Code>" + parts[0] + "</Code><Message>" + parts[1] + "</Message></Error>", "application/xml", null);
return;
}
String path = ex.getRequestURI().getRawPath();
if (ex.getRequestMethod().equals("PUT")) {
objects.put(path, new Obj(ex.getRequestBody().readAllBytes(), ex.getRequestHeaders().getFirst("Content-Type")));
reply(ex, 200, "", "text/plain", null);
} else if (ex.getRequestMethod().equals("GET")) {
Obj o = objects.get(path);
if (o == null) {
reply(ex, 404, "<Error><Code>NoSuchKey</Code></Error>", "application/xml", null);
return;
}
String cd = queryParams(ex).get("response-content-disposition");
reply(ex, 200, o.data(), o.contentType() == null ? "application/octet-stream" : o.contentType(),
cd == null ? null : URLDecoder.decode(cd, StandardCharsets.UTF_8));
} else {
reply(ex, 405, "", "text/plain", null);
}
} catch (Exception e) {
reply(ex, 500, e.toString(), "text/plain", null);
}
}
private static Map<String, String> queryParams(HttpExchange ex) {
Map<String, String> m = new TreeMap<>();
String raw = ex.getRequestURI().getRawQuery();
if (raw != null) {
for (String kv : raw.split("&")) {
int i = kv.indexOf('=');
m.put(i < 0 ? kv : kv.substring(0, i), i < 0 ? "" : kv.substring(i + 1));
}
}
return m;
}
/** Returns null if the signature is valid, otherwise "Code|Message". */
private String verify(HttpExchange ex) throws Exception {
Map<String, String> q = queryParams(ex);
String signature = q.get("X-Amz-Signature");
if (signature == null || !"AWS4-HMAC-SHA256".equals(q.get("X-Amz-Algorithm"))) {
return "AccessDenied|Query-string authentication requires the X-Amz-Algorithm and X-Amz-Signature parameters";
}
String amzDate = q.get("X-Amz-Date");
long expires = Long.parseLong(q.get("X-Amz-Expires"));
Instant signedAt = LocalDateTime.parse(amzDate, DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'")).toInstant(ZoneOffset.UTC);
if (Instant.now().isAfter(signedAt.plusSeconds(expires))) {
return "AccessDenied|Request has expired";
}
String credential = URLDecoder.decode(q.get("X-Amz-Credential"), StandardCharsets.UTF_8);
String[] scope = credential.split("/"); // accessKey/date/region/service/aws4_request
String signedHeaders = URLDecoder.decode(q.get("X-Amz-SignedHeaders"), StandardCharsets.UTF_8);
Map<String, String> signedQuery = new TreeMap<>(q);
signedQuery.remove("X-Amz-Signature");
List<String> pairs = new ArrayList<>();
signedQuery.forEach((k, v) -> pairs.add(k + "=" + v));
StringBuilder canonicalHeaders = new StringBuilder();
for (String h : signedHeaders.split(";")) {
String value = h.equals("host") ? ex.getRequestHeaders().getFirst("Host") : ex.getRequestHeaders().getFirst(h);
canonicalHeaders.append(h).append(':').append(value == null ? "" : value.trim()).append('\n');
}
String canonicalRequest = ex.getRequestMethod() + "\n" + ex.getRequestURI().getRawPath() + "\n"
+ String.join("&", pairs) + "\n" + canonicalHeaders + "\n" + signedHeaders + "\nUNSIGNED-PAYLOAD";
String stringToSign = "AWS4-HMAC-SHA256\n" + amzDate + "\n" + scope[1] + "/" + scope[2] + "/" + scope[3] + "/aws4_request\n"
+ hex(MessageDigest.getInstance("SHA-256").digest(canonicalRequest.getBytes(StandardCharsets.UTF_8)));
byte[] k = hmac(("AWS4" + secretKey).getBytes(StandardCharsets.UTF_8), scope[1]);
k = hmac(k, scope[2]);
k = hmac(k, scope[3]);
k = hmac(k, "aws4_request");
String expected = hex(hmac(k, stringToSign));
return expected.equals(signature) ? null
: "SignatureDoesNotMatch|The request signature we calculated does not match the signature you provided";
}
private static byte[] hmac(byte[] key, String data) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
}
private static String hex(byte[] b) {
return HexFormat.of().formatHex(b);
}
private static void reply(HttpExchange ex, int status, String body, String type, String disposition) throws IOException {
reply(ex, status, body.getBytes(StandardCharsets.UTF_8), type, disposition);
}
private static void reply(HttpExchange ex, int status, byte[] body, String type, String disposition) throws IOException {
ex.getResponseHeaders().set("Content-Type", type);
if (disposition != null) {
ex.getResponseHeaders().set("Content-Disposition", disposition);
}
ex.sendResponseHeaders(status, body.length == 0 ? -1 : body.length);
if (body.length > 0) {
ex.getResponseBody().write(body);
}
ex.close();
}
}
@@ -0,0 +1,92 @@
package com.ankurm.filetransfer;
import static org.assertj.core.api.Assertions.assertThat;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import com.ankurm.filetransfer.Cap.Part;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class FilenameAndDownloadTest {
static final String F = "02-filename.txt";
static final String G = "03-download.txt";
@LocalServerPort int port;
@Autowired FileStore store;
@BeforeAll
static void clean() {
Cap.reset(F);
Cap.reset(G);
}
String base() {
return "http://localhost:" + port;
}
String postRaw(String filename, String contentType, byte[] content) throws Exception {
String boundary = "----ft" + System.nanoTime();
byte[] body = Cap.multipart(boundary, List.of(new Part("file", filename, contentType, content)));
HttpRequest req = HttpRequest.newBuilder(URI.create(base() + "/files"))
.header("Content-Type", "multipart/form-data; boundary=" + boundary)
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
return Cap.HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
@Test
void whatTheServerSeesIsWhatTheClientSent() throws Exception {
String evil = "../../etc/cron.d/evil.txt";
String json = postRaw(evil, "image/png", "hello".getBytes(StandardCharsets.UTF_8));
Cap.say(F, "client sent filename=" + evil + " Content-Type=image/png");
Cap.say(F, "server response: " + json);
Path root = store.root();
boolean escapes = !root.resolve(evil).normalize().startsWith(root);
Cap.say(F, "root.resolve(originalFilename).normalize() stays inside the storage dir: " + !escapes);
assertThat(json).contains("\"displayName\":\"evil.txt\"");
assertThat(escapes).isTrue();
String win = postRaw("..\\..\\windows\\system32\\evil.dll", "text/plain", "x".getBytes(StandardCharsets.UTF_8));
Cap.say(F, "client sent a Windows-style path; server response: " + win);
}
@Test
void downloadSupportsRangeAndEncodesNames() throws Exception {
String json = postRaw("résumé 2026.txt", "text/plain", "ABCDEFGHIJKLMNOPQRSTUVWXYZ".getBytes(StandardCharsets.UTF_8));
String id = json.replaceAll(".*\"id\":\"([^\"]+)\".*", "$1");
HttpResponse<String> full = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id)).build(),
HttpResponse.BodyHandlers.ofString());
Cap.say(G, "GET /files/{id}");
Cap.say(G, " status=" + full.statusCode() + " body=" + full.body());
for (String h : List.of("content-type", "content-length", "content-disposition", "accept-ranges", "x-content-type-options")) {
Cap.say(G, " " + h + ": " + full.headers().firstValue(h).orElse("(absent)"));
}
HttpResponse<String> part = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id))
.header("Range", "bytes=10-19").build(), HttpResponse.BodyHandlers.ofString());
Cap.say(G, "GET /files/{id} with Range: bytes=10-19");
Cap.say(G, " status=" + part.statusCode() + " body=" + part.body());
Cap.say(G, " content-range: " + part.headers().firstValue("content-range").orElse("(absent)"));
HttpResponse<String> past = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(base() + "/files/" + id))
.header("Range", "bytes=500-").build(), HttpResponse.BodyHandlers.ofString());
Cap.say(G, "GET /files/{id} with Range: bytes=500-");
Cap.say(G, " status=" + past.statusCode() + " content-range: " + past.headers().firstValue("content-range").orElse("(absent)"));
assertThat(part.statusCode()).isEqualTo(206);
assertThat(part.body()).isEqualTo("KLMNOPQRST");
}
}
@@ -0,0 +1,74 @@
package com.ankurm.filetransfer;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
import com.ankurm.filetransfer.Cap.Part;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class MultipartLimitsTest {
static final String F = "01-limits.txt";
static final int KB = 1024;
@LocalServerPort int port;
@Autowired MockMvc mvc;
@BeforeAll
static void clean() {
Cap.reset(F);
}
String base() {
return "http://localhost:" + port;
}
@Test
void limitsAreEnforcedByTheContainerNotByMockMvc() throws Exception {
Cap.say(F, "limits: max-file-size=1MB, max-request-size=2MB");
String small = Cap.postMultipart(base(), List.of(new Part("file", "a.txt", "text/plain", Cap.bytes(500 * KB))));
Cap.say(F, "real HTTP, one 500 KB file -> " + small);
String big = Cap.postMultipart(base(), List.of(new Part("file", "b.txt", "text/plain", Cap.bytes(1536 * KB))));
Cap.say(F, "real HTTP, one 1.5 MB file -> " + big);
List<Part> three = List.of(
new Part("file", "c1.txt", "text/plain", Cap.bytes(900 * KB)),
new Part("file", "c2.txt", "text/plain", Cap.bytes(900 * KB)),
new Part("file", "c3.txt", "text/plain", Cap.bytes(900 * KB)));
String request = Cap.postMultipart(base(), three);
Cap.say(F, "real HTTP, 3 x 900 KB in one request -> " + request);
// Tomcat only swallows a limited amount (maxSwallowSize, 2 MB) of a rejected upload before it closes the
// connection, so a client sending far more than that may see a reset instead of the 413. Count outcomes.
int got413 = 0, reset = 0;
for (int i = 0; i < 10; i++) {
String huge = Cap.postMultipart(base(), List.of(new Part("file", "d.bin", "application/octet-stream", Cap.bytes(10 * 1024 * KB))));
if (huge.startsWith("status=413")) got413++;
else reset++;
}
Cap.say(F, "real HTTP, one 10 MB file, 10 attempts -> " + got413 + " x status=413, " + reset + " x connection error");
int mockStatus = mvc.perform(MockMvcRequestBuilders.multipart("/files")
.file(new MockMultipartFile("file", "b.txt", "text/plain", Cap.bytes(1536 * KB))))
.andReturn().getResponse().getStatus();
Cap.say(F, "MockMvc, the same 1.5 MB file -> status=" + mockStatus);
assertThat(small).startsWith("status=201");
assertThat(big).startsWith("status=413");
assertThat(mockStatus).isEqualTo(201);
}
}
@@ -0,0 +1,118 @@
package com.ankurm.filetransfer;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Arrays;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class PresignedUrlTest {
static final String F = "04-presign.txt";
static final String SECRET = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY";
static FakeS3 s3;
@LocalServerPort int port;
@Autowired S3PresignService presign;
JsonMapper json = JsonMapper.builder().build();
@DynamicPropertySource
static void s3(DynamicPropertyRegistry r) throws IOException {
s3 = new FakeS3(SECRET);
r.add("app.s3.endpoint", s3::endpoint);
}
@BeforeAll
static void clean() {
Cap.reset(F);
}
@AfterAll
static void stop() {
s3.close();
}
HttpResponse<String> put(String url, String contentType, byte[] body) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(url)).PUT(HttpRequest.BodyPublishers.ofByteArray(body));
if (contentType != null) {
b.header("Content-Type", contentType);
}
return Cap.HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
}
static String brief(HttpResponse<String> r) {
String body = r.body().replaceAll("<Message>.*</Message>", "").replaceAll("\\s+", " ");
return "status=" + r.statusCode() + (body.isBlank() ? "" : " " + body);
}
@Test
void presignedUploadAndDownload() throws Exception {
HttpResponse<String> res = Cap.HTTP.send(HttpRequest.newBuilder(URI.create("http://localhost:" + port + "/presign/upload"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"contentType\":\"image/png\"}")).build(), HttpResponse.BodyHandlers.ofString());
JsonNode node = json.readTree(res.body());
String url = node.get("url").asString();
String key = node.get("key").asString();
Cap.say(F, "POST /presign/upload {\"contentType\":\"image/png\"} -> status=" + res.statusCode());
Cap.say(F, " method=" + node.get("method").asString() + " expiresInSeconds=" + node.get("expiresInSeconds").asLong()
+ " headers=" + node.get("headers"));
String[] split = url.split("\\?");
Cap.say(F, " url path: " + split[0].replaceFirst("http://localhost:\\d+", "http://<fake-s3>"));
for (String p : split[1].split("&")) {
Cap.say(F, " query: " + p);
}
byte[] png = {(byte) 0x89, 'P', 'N', 'G', 1, 2, 3, 4};
Cap.say(F, "PUT with the signed Content-Type (image/png) -> " + brief(put(url, "image/png", png)));
assertThat(s3.objects.values().iterator().next().data()).isEqualTo(png);
Cap.say(F, "PUT the same URL again (presigned URLs are reusable) -> " + brief(put(url, "image/png", png)));
Cap.say(F, "PUT with a different Content-Type (text/html) -> " + brief(put(url, "text/html", png)));
Cap.say(F, "PUT with no Content-Type header -> " + brief(put(url, null, png)));
String tampered = url.replace(key, key.substring(0, key.length() - 1) + (key.endsWith("a") ? "b" : "a"));
Cap.say(F, "PUT after changing the last character of the key -> " + brief(put(tampered, "image/png", png)));
String longer = url.replace("X-Amz-Expires=300", "X-Amz-Expires=604800");
Cap.say(F, "PUT after raising X-Amz-Expires to 7 days -> " + brief(put(longer, "image/png", png)));
// download with a Content-Disposition override
HttpResponse<String> dl = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(
"http://localhost:" + port + "/presign/download/" + key + "?filename=holiday.png")).build(), HttpResponse.BodyHandlers.ofString());
String getUrl = json.readTree(dl.body()).get("url").asString();
HttpResponse<byte[]> got = Cap.HTTP.send(HttpRequest.newBuilder(URI.create(getUrl)).build(), HttpResponse.BodyHandlers.ofByteArray());
Cap.say(F, "GET presigned URL -> status=" + got.statusCode() + " bytes match upload=" + Arrays.equals(got.body(), png)
+ " content-disposition=" + got.headers().firstValue("content-disposition").orElse("(absent)"));
Cap.say(F, "disallowed type: POST /presign/upload text/html -> status=" + Cap.HTTP.send(HttpRequest.newBuilder(
URI.create("http://localhost:" + port + "/presign/upload")).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"contentType\":\"text/html\"}")).build(), HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void expiredUrlIsRejected() throws Exception {
S3PresignService.Upload u = presign.forUpload("image/png", Duration.ofSeconds(1));
Thread.sleep(2200);
HttpResponse<String> r = put(u.url(), "image/png", "x".getBytes(StandardCharsets.UTF_8));
Cap.say(F, "PUT a URL signed for 1 second, sent after 2.2 seconds -> " + brief(r));
assertThat(r.statusCode()).isEqualTo(403);
}
}
+2
View File
@@ -0,0 +1,2 @@
.gradle/
build/
+14
View File
@@ -0,0 +1,14 @@
# gradle9: moving a Spring Boot 4 build to Gradle 9
Companion code for the ankurm.com post "Gradle 9 for Spring Boot 4 Projects: Configuration Cache, Version Catalogs and Kotlin DSL".
| Folder | What it is |
|---|---|
| `before/` | A legacy Groovy DSL build (Boot 4.1.1) that works on Gradle 8.14 and breaks on Gradle 9 |
| `after/` | The same project migrated: Kotlin DSL, version catalog, configuration-cache-safe custom task, wrapper on Gradle 9.8.0 |
| `scripts/run-all.sh` | Regenerates everything under `output/` (env vars `G8`, `G9`, `J21`, `J25` point to the tools) |
| `output/` | Captured transcripts, the only source for numbers and messages quoted in the post |
Tested with Gradle 8.14.3 (JDK 21) and Gradle 9.8.0 (JDK 25.0.4.1), Spring Boot 4.1.1, on 28 September 2026.
Quick start: `cd after && ./gradlew build --configuration-cache`.
+35
View File
@@ -0,0 +1,35 @@
plugins {
java
alias(libs.plugins.spring.boot)
alias(libs.plugins.dependency.management)
}
group = "com.ankurm"
version = "0.0.1"
tasks.withType<JavaCompile>().configureEach { options.release = 17 }
base { archivesName = "g9demo" }
repositories { mavenCentral() }
dependencies {
implementation(libs.starter.webmvc)
testImplementation(libs.starter.webmvc.test)
}
// Configuration-cache-safe: a typed task whose inputs are Property/Provider values,
// so nothing reaches back into the script or Project when the task executes.
abstract class BuildInfo : DefaultTask() {
@get:Input abstract val text: Property<String>
@get:OutputFile abstract val target: RegularFileProperty
@TaskAction fun write() { target.get().asFile.writeText(text.get()) }
}
tasks.register<BuildInfo>("buildInfo") {
val prefix = "${project.name} ${project.version} " // read at configuration time
text = providers.environmentVariable("BUILD_STAMP").orElse("local").map { "$prefix$it\n" }
target = layout.buildDirectory.file("build-info.txt")
}
tasks.named<Test>("test") { useJUnitPlatform() }
+11
View File
@@ -0,0 +1,11 @@
[versions]
spring-boot = "4.1.1"
dependency-management = "1.1.7"
[libraries]
starter-webmvc = { module = "org.springframework.boot:spring-boot-starter-webmvc" }
starter-webmvc-test = { module = "org.springframework.boot:spring-boot-starter-webmvc-test" }
[plugins]
spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" }
dependency-management = { id = "io.spring.dependency-management", version.ref = "dependency-management" }
Binary file not shown.
@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+112
View File
@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+1
View File
@@ -0,0 +1 @@
rootProject.name = "g9demo"
@@ -0,0 +1,15 @@
package com.ankurm.g9;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class G9Application {
public static void main(String[] args) { SpringApplication.run(G9Application.class, args); }
@GetMapping("/hello")
String hello() { return "hello"; }
}
@@ -0,0 +1,21 @@
package com.ankurm.g9;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class G9ApplicationTest {
@Autowired MockMvc mvc;
@Test
void hello() throws Exception {
mvc.perform(get("/hello")).andExpect(content().string("hello"));
}
}
+31
View File
@@ -0,0 +1,31 @@
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.ankurm'
version = '0.0.1'
sourceCompatibility = '17'
archivesBaseName = 'g9demo-legacy'
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
}
// Task that reads the project model and an environment variable while it EXECUTES
tasks.register('buildInfo') {
doLast {
def stamp = System.getenv('BUILD_STAMP') ?: 'local'
file("$buildDir/build-info.txt").text = "${project.name} ${project.version} ${stamp}\n"
}
}
tasks.named('test') {
useJUnitPlatform()
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'g9demo'
@@ -0,0 +1,15 @@
package com.ankurm.g9;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@SpringBootApplication
@RestController
public class G9Application {
public static void main(String[] args) { SpringApplication.run(G9Application.class, args); }
@GetMapping("/hello")
String hello() { return "hello"; }
}
@@ -0,0 +1,21 @@
package com.ankurm.g9;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
@SpringBootTest
@AutoConfigureMockMvc
class G9ApplicationTest {
@Autowired MockMvc mvc;
@Test
void hello() throws Exception {
mvc.perform(get("/hello")).andExpect(content().string("hello"));
}
}
+11
View File
@@ -0,0 +1,11 @@
$ gradle --version (Gradle 8)
Gradle 8.14.3
Kotlin: 2.0.21
Groovy: 3.0.24
Launcher JVM: 21.0.10 (Ubuntu 21.0.10+7-Ubuntu-124.04)
$ gradle --version (Gradle 9)
Gradle 9.8.0
Kotlin: 2.4.10
Groovy: 4.0.33
Launcher JVM: 25.0.4.1 (Eclipse Adoptium 25.0.4.1+1-LTS)
+14
View File
@@ -0,0 +1,14 @@
$ gradle build --warning-mode all # Gradle 8.14.3, JDK 21, legacy Groovy build
Build file 'WORK/a/build.gradle': line 9
The org.gradle.api.plugins.JavaPluginConvention type has been deprecated. This is scheduled to be removed in Gradle 9.0. Consult the upgrading guide for further information: https://docs.gradle.org/8.14.3/userguide/upgrading_version_8.html#java_convention_deprecation
at build_dlrldupk0ke03lzjij3dzgrv1.run(WORK/a/build.gradle:9)
(Run with --stacktrace to get the full stack trace of this deprecation warning.)
Build file 'WORK/a/build.gradle': line 10
The org.gradle.api.plugins.BasePluginConvention type has been deprecated. This is scheduled to be removed in Gradle 9.0. Consult the upgrading guide for further information: https://docs.gradle.org/8.14.3/userguide/upgrading_version_8.html#base_convention_deprecation
at build_dlrldupk0ke03lzjij3dzgrv1.run(WORK/a/build.gradle:10)
(Run with --stacktrace to get the full stack trace of this deprecation warning.)
Build file 'WORK/a/build.gradle': line 10
The BasePluginExtension.archivesBaseName property has been deprecated. This is scheduled to be removed in Gradle 9.0. Please use the archivesName property instead. For more information, please refer to https://docs.gradle.org/8.14.3/dsl/org.gradle.api.plugins.BasePluginExtension.html#org.gradle.api.plugins.BasePluginExtension:archivesName in the Gradle documentation.
at build_dlrldupk0ke03lzjij3dzgrv1.run(WORK/a/build.gradle:10)
(Run with --stacktrace to get the full stack trace of this deprecation warning.)
BUILD SUCCESSFUL
@@ -0,0 +1,5 @@
$ gradle build # Gradle 9.8.0, JDK 25, same legacy build, unchanged
Build file 'WORK/b/build.gradle' line: 9
* What went wrong:
> Could not set unknown property 'sourceCompatibility' for root project 'g9demo' of type org.gradle.api.Project.
BUILD FAILED
@@ -0,0 +1,5 @@
$ gradle build # after replacing sourceCompatibility = '17' with java { sourceCompatibility = ... }
Build file 'WORK/c/build.gradle' line: 10
* What went wrong:
> Could not set unknown property 'archivesBaseName' for root project 'g9demo' of type org.gradle.api.Project.
BUILD FAILED
@@ -0,0 +1,6 @@
$ gradle build buildInfo --warning-mode all # both removals fixed
Build file 'WORK/d/build.gradle': line 25
Invocation of Task.project at execution time has been deprecated. This will fail with an error in Gradle 10. This API is incompatible with the configuration cache, which will become the only mode supported by Gradle in a future release. Consult the upgrading guide for further information: https://docs.gradle.org/9.8.0/userguide/upgrading_version_7.html#task_project
at build_9kjx5jbekfxgd2u4kdta23gdc$_run_closure5$_closure7.doCall$original(WORK/d/build.gradle:25)
(Run with --stacktrace to get the full stack trace of this deprecation warning.)
BUILD SUCCESSFUL
@@ -0,0 +1,11 @@
$ gradle buildInfo --configuration-cache # same fixed legacy build
> Task :buildInfo FAILED
1 problem was found storing the configuration cache.
- Build file 'build.gradle': line 25: invocation of 'Task.project' at execution time is unsupported with the configuration cache.
[Incubating] Problems report is available at: file://WORK/d/build/reports/problems/problems-report.html
Build file 'WORK/d/build.gradle' line: 25
* What went wrong:
Execution failed for task ':buildInfo' (registered in build file 'build.gradle').
> Invocation of 'Task.project' by task ':buildInfo' at execution time is unsupported with the configuration cache.
BUILD FAILED
Configuration cache entry discarded with 1 problem.
@@ -0,0 +1,21 @@
$ ./gradlew buildInfo test --configuration-cache # migrated build, first run
> Task :buildInfo
> Task :testClasses
> Task :test
BUILD SUCCESSFUL
Configuration cache entry stored.
$ ./gradlew buildInfo test --configuration-cache # second run
Reusing configuration cache.
> Task :buildInfo UP-TO-DATE
> Task :testClasses UP-TO-DATE
> Task :test UP-TO-DATE
BUILD SUCCESSFUL
Configuration cache entry reused.
$ BUILD_STAMP=ci42 ./gradlew buildInfo --configuration-cache # env var changed
BUILD SUCCESSFUL
Configuration cache entry stored.
$ cat build/build-info.txt
g9demo 0.0.1 ci42
+10
View File
@@ -0,0 +1,10 @@
$ ./gradlew build # migrated build on the Gradle wrapper (9.8.0), tests included
> Task :bootJar
> Task :testClasses
> Task :test
BUILD SUCCESSFUL
7 actionable tasks: 7 executed
$ ls build/libs
g9demo-0.0.1-plain.jar
g9demo-0.0.1.jar
+10
View File
@@ -0,0 +1,10 @@
$ gradle build # Gradle 8.14.3 on JDK 25 (launcher JVM), migrated build
FAILURE: Build failed with an exception.
* What went wrong:
25.0.4.1
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# Regenerates output/*.txt. Needs: G8 (Gradle 8.14.x), G9 (Gradle 9.x), J21 and J25 (JAVA_HOME dirs).
set -u
G8=${G8:-/opt/gradle/bin/gradle}; G9=${G9:-/tmp/tools/gradle-9.8.0/bin/gradle}
J21=${J21:-/usr/lib/jvm/java-21-openjdk-amd64}; J25=${J25:-/tmp/tools/jdk-25.0.4.1+1}
HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/g9-work}
mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
clean() { grep -v 'JAVA_TOOL_OPTIONS' | sed "s#$W#WORK#g; s#$HERE#REPO#g; s/ in [0-9msh ]*\$//; /^Daemon will be stopped/d; /^Starting a Gradle Daemon/d; /^See the complete report/d"; }
run() { # run <javahome> <dir> <gradle> args...
local jh=$1 d=$2 g=$3; shift 3; (cd "$d" && JAVA_HOME=$jh "$g" --console=plain "$@" 2>&1 | clean); }
fresh() { rm -rf "$W/$1"; cp -r "$HERE/before" "$W/$1"; rm -rf "$W/$1/build" "$W/$1/.gradle"; }
{ echo '$ gradle --version (Gradle 8)'; JAVA_HOME=$J21 $G8 --version 2>&1 | grep -E '^Gradle|^Launcher|^Kotlin|^Groovy'
echo; echo '$ gradle --version (Gradle 9)'; JAVA_HOME=$J25 $G9 --version 2>&1 | grep -E '^Gradle|^Launcher|^Kotlin|^Groovy'; } > "$OUT/00-environment.txt"
fresh a
{ echo '$ gradle build --warning-mode all # Gradle 8.14.3, JDK 21, legacy Groovy build'
run "$J21" "$W/a" "$G8" build --warning-mode all | grep -E 'deprecat|Deprecat|BUILD|build.gradle|Gradle 9|Gradle 10|Task.project' ; } > "$OUT/01-legacy-on-gradle8.txt"
fresh b
{ echo '$ gradle build # Gradle 9.8.0, JDK 25, same legacy build, unchanged'
run "$J25" "$W/b" "$G9" build | grep -E 'What went wrong|Could not|BUILD|build.gradle' ; } > "$OUT/02-legacy-on-gradle9-sourceCompatibility.txt"
fresh c
python3 - "$W/c/build.gradle" <<'PY'
import sys; p=sys.argv[1]; s=open(p).read()
s=s.replace("sourceCompatibility = '17'","java { sourceCompatibility = JavaVersion.VERSION_17 }"); open(p,'w').write(s)
PY
{ echo "\$ gradle build # after replacing sourceCompatibility = '17' with java { sourceCompatibility = ... }"
run "$J25" "$W/c" "$G9" build | grep -E 'What went wrong|Could not|BUILD|build.gradle' ; } > "$OUT/03-legacy-on-gradle9-archivesBaseName.txt"
fresh d
python3 - "$W/d/build.gradle" <<'PY'
import sys; p=sys.argv[1]; s=open(p).read()
s=s.replace("sourceCompatibility = '17'","java { sourceCompatibility = JavaVersion.VERSION_17 }")
s=s.replace("archivesBaseName = 'g9demo-legacy'","base { archivesName = 'g9demo-legacy' }"); open(p,'w').write(s)
PY
{ echo '$ gradle build buildInfo --warning-mode all # both removals fixed'
run "$J25" "$W/d" "$G9" build buildInfo --warning-mode all | grep -E 'deprecat|Deprecat|BUILD|Gradle 10|Task.project|build.gradle' ; } > "$OUT/04-legacy-fixed-on-gradle9.txt"
rm -rf "$W/d/build" "$W/d/.gradle"
{ echo '$ gradle buildInfo --configuration-cache # same fixed legacy build'
run "$J25" "$W/d" "$G9" buildInfo --configuration-cache | grep -E 'What went wrong|Task.project|BUILD|onfiguration cache|problem|build.gradle|FAILED' ; } > "$OUT/05-legacy-configuration-cache.txt"
rm -rf "$W/after"; cp -r "$HERE/after" "$W/after"; rm -rf "$W/after/build" "$W/after/.gradle"
{ echo '$ ./gradlew buildInfo test --configuration-cache # migrated build, first run'
run "$J25" "$W/after" "$W/after/gradlew" buildInfo test --configuration-cache | grep -E 'BUILD|onfiguration cache|Task :(buildInfo|test)'
echo; echo '$ ./gradlew buildInfo test --configuration-cache # second run'
run "$J25" "$W/after" "$W/after/gradlew" buildInfo test --configuration-cache | grep -E 'BUILD|onfiguration cache|Task :(buildInfo|test)'
echo; echo '$ BUILD_STAMP=ci42 ./gradlew buildInfo --configuration-cache # env var changed'
(cd "$W/after" && BUILD_STAMP=ci42 JAVA_HOME=$J25 ./gradlew --console=plain buildInfo --configuration-cache 2>&1 | clean | grep -E 'BUILD|onfiguration cache|input|BUILD_STAMP')
echo; echo '$ cat build/build-info.txt'; cat "$W/after/build/build-info.txt"; } > "$OUT/06-migrated-configuration-cache.txt"
{ echo '$ ./gradlew build # migrated build on the Gradle wrapper (9.8.0), tests included'
run "$J25" "$W/after" "$W/after/gradlew" clean build | grep -E 'BUILD|Task :(test|bootJar)|actionable'
echo; echo '$ ls build/libs'; ls "$W/after/build/libs"; } > "$OUT/07-migrated-build.txt"
{ echo '$ gradle build # Gradle 8.14.3 on JDK 25 (launcher JVM), migrated build'
(cd "$W/after" && JAVA_HOME=$J25 $G8 --console=plain build 2>&1 | clean | grep -vE '^$' | head -12); } > "$OUT/08-gradle8-on-jdk25.txt"
echo done
+86
View File
@@ -0,0 +1,86 @@
# hexagonal
Companion project for the article **[Hexagonal Architecture (Ports and Adapters) in Spring Boot 4](https://ankurm.com/hexagonal-architecture-ports-and-adapters-spring-boot-4/)** on **[ankurm.com](https://ankurm.com)**.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible "going
deeper" sections inside the article itself, next to the paragraph each one extends.
This is also the one project in this repo that is its own small Maven reactor rather than a
single self-contained project. That is not a style choice -- it is what makes "the core has no
Spring dependency" something Maven enforces, not a naming convention a reviewer has to remember
to check. Run `mvn -pl core dependency:tree` yourself and compare it to `core/output/01-core-dependency-tree.txt`.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| JDK | 25 (Temurin 25.0.4.1+1) |
| Maven | 3.9 |
| H2 | 2.4.240 (the version Boot 4.1.1 manages) |
| Hibernate ORM | 7.4.5.Final |
## Modules
| Module | What it is | Depends on | Spring on its compile classpath? |
|---|---|---|---|
| [`core/`](core) | the domain model, the inbound/outbound ports, `WalletService` | nothing in this reactor | **no** -- `mvn -pl core dependency:tree` has zero Spring artifacts |
| [`persistence-adapter/`](persistence-adapter) | the outbound adapter: `WalletEntity`, `SpringDataWalletRepository`, `JpaWalletRepositoryAdapter` | `core` | yes -- Spring Data JPA, H2 |
| [`web-adapter/`](web-adapter) | the inbound adapter: `WalletController`, `WalletExceptionHandler` | `core` | yes -- Spring MVC |
| [`app/`](app) | the composition root: `HexagonalApplication`, the only `@SpringBootApplication` in this project | `core`, `persistence-adapter`, `web-adapter` | yes -- it wires everything together |
## Quickstart
```bash
export JAVA_HOME=/path/to/jdk-25
cd hexagonal
mvn clean install # builds all four modules in order, runs every test
```
To run the real application:
```bash
cd app
mvn spring-boot:run
curl -X POST "http://localhost:8080/wallets/w1/deposit?amountCents=500"
```
(there's no endpoint to create a wallet first -- the H2 schema starts empty; the application
test seeds one directly through `WalletRepository` before calling HTTP, which is itself worth
reading as an example of testing through the port rather than through a seed script.)
## Captured output
| File | What it shows |
|---|---|
| [`core/output/00-core-tests-no-spring.txt`](core/output/00-core-tests-no-spring.txt) | `WalletServiceTest`'s 4 tests, no Spring context anywhere, finishing in milliseconds |
| [`core/output/01-core-dependency-tree.txt`](core/output/01-core-dependency-tree.txt) | the full `mvn dependency:tree` for `hexagonal-core` -- JUnit and AssertJ, nothing else |
| [`persistence-adapter/output/00-jpa-adapter-tests.txt`](persistence-adapter/output/00-jpa-adapter-tests.txt) | `@DataJpaTest` against a real H2 database: a save-then-find round trip and an unknown-id lookup |
| [`web-adapter/output/00-webmvctest-mockmvc.txt`](web-adapter/output/00-webmvctest-mockmvc.txt) | `@WebMvcTest` with the ports mocked: the real JSON a deposit and a rejected withdrawal return |
| [`app/output/00-end-to-end-http.txt`](app/output/00-end-to-end-http.txt) | the full stack over real HTTP: deposit, withdraw, an overdraft rejected with 409, and the balance reloaded straight from the database to cross-check the HTTP response |
| [`app/output/01-missing-scan-base-packages-failure.txt`](app/output/01-missing-scan-base-packages-failure.txt) | the real `NoSuchBeanDefinitionException` that comes back if `@EnableJpaRepositories`/`@EntityScan` are removed from `HexagonalApplication` -- captured by actually removing them and running the test, not reconstructed from memory |
## A note for anyone copying this reactor shape
Two things cost real time while building this that are worth stating plainly rather than
leaving a reader to rediscover them:
- Spring Boot 4 split most `@...Test` annotations out of `spring-boot-test-autoconfigure` into
their own small artifacts, each under a *new* package too (`@DataJpaTest` is now
`org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest` in artifact
`spring-boot-data-jpa-test`; `@WebMvcTest` is `org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest`
in `spring-boot-webmvc-test`). `TestRestTemplate` survived as itself but moved to
`org.springframework.boot.resttestclient.TestRestTemplate` in artifact `spring-boot-resttestclient`,
and it is no longer auto-configured on `@SpringBootTest(webEnvironment = RANDOM_PORT)` --
`app`'s test needs an explicit `@AutoConfigureTestRestTemplate`.
- In a reactor where the `@SpringBootApplication` class's package is a *sibling* of the
persistence adapter's package rather than an ancestor, Spring Data JPA's repository scan and
JPA's entity scan both default to the main class's own package -- not `scanBasePackages`.
`HexagonalApplication` needs an explicit `@EnableJpaRepositories(basePackages = "...")` and
`@EntityScan(basePackages = "...")` pointing at `com.ankurm.hexagonal.persistence`, or the
adapter bean fails to wire with no hint that scanning is the cause.
## Licence
MIT -- see the root [LICENSE](../LICENSE).
@@ -0,0 +1,98 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ----------------------< com.ankurm:hexagonal-app >----------------------
[INFO] Building hexagonal-app 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-app ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/app/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-app ---
[INFO] Copying 1 resource from src/main/resources to target/classes
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-app ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 1 source file with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-app ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/app/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-app ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 1 source file with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.2.5:test (default-test) @ hexagonal-app ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.hexagonal.app.HexagonalApplicationTest
01:55:11.470 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.app.HexagonalApplicationTest]: HexagonalApplicationTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:55:11.578 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.app.HexagonalApplication for test class com.ankurm.hexagonal.app.HexagonalApplicationTest
01:55:11.662 [main] INFO org.springframework.test.context.support.AnnotationConfigContextLoaderUtils -- Could not detect default configuration classes for test class [com.ankurm.hexagonal.app.HexagonalApplicationTest]: HexagonalApplicationTest does not declare any static, non-private, non-final, nested classes annotated with @Configuration.
01:55:11.666 [main] INFO org.springframework.boot.test.context.SpringBootTestContextBootstrapper -- Found @SpringBootConfiguration com.ankurm.hexagonal.app.HexagonalApplication for test class com.ankurm.hexagonal.app.HexagonalApplicationTest
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.1.1)
2026-10-04T01:55:12.046+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : Starting HexagonalApplicationTest using Java 25.0.4.1 with PID 14110 (started by root in /home/claude/spring-boot-demo/hexagonal/app)
2026-10-04T01:55:12.052+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : No active profile set, falling back to 1 default profile: "default"
2026-10-04T01:55:12.587+05:30 INFO 14110 --- [hexagonal] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Bootstrapping Spring Data JPA repositories in DEFAULT mode.
2026-10-04T01:55:12.628+05:30 INFO 14110 --- [hexagonal] [ main] .s.d.r.c.RepositoryConfigurationDelegate : Finished Spring Data repository scanning in 30 ms. Found 1 JPA repository interface.
2026-10-04T01:55:13.334+05:30 INFO 14110 --- [hexagonal] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat initialized with port 0 (http)
2026-10-04T01:55:13.349+05:30 INFO 14110 --- [hexagonal] [ main] o.apache.catalina.core.StandardService : Starting service [Tomcat]
2026-10-04T01:55:13.351+05:30 INFO 14110 --- [hexagonal] [ main] o.apache.catalina.core.StandardEngine : Starting Servlet engine: [Apache Tomcat/11.0.24]
2026-10-04T01:55:13.393+05:30 INFO 14110 --- [hexagonal] [ main] b.w.c.s.WebApplicationContextInitializer : Root WebApplicationContext: initialization completed in 1302 ms
2026-10-04T01:55:13.615+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.jpa : HHH008540: Processing PersistenceUnitInfo [name: default]
2026-10-04T01:55:13.651+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.core : HHH000001: Hibernate ORM core version 7.4.5.Final
2026-10-04T01:55:13.939+05:30 INFO 14110 --- [hexagonal] [ main] o.s.o.j.p.SpringPersistenceUnitInfo : No LoadTimeWeaver setup: ignoring JPA class transformer
2026-10-04T01:55:13.962+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Starting...
2026-10-04T01:55:14.080+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.pool.HikariPool : HikariPool-1 - Added connection conn0: url=jdbc:h2:mem:04350710-e63b-41db-99c0-21493685b159 user=SA
2026-10-04T01:55:14.081+05:30 INFO 14110 --- [hexagonal] [ main] com.zaxxer.hikari.HikariDataSource : HikariPool-1 - Start completed.
2026-10-04T01:55:14.125+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.connections.pooling : HHH10001005: Database info:
Database JDBC URL [jdbc:h2:mem:04350710-e63b-41db-99c0-21493685b159]
Database driver: H2 JDBC Driver
Database dialect: H2Dialect
Database version: 2.4.240
Default catalog/schema: 04350710-E63B-41DB-99C0-21493685B159/PUBLIC
Autocommit mode: undefined/unknown
Isolation level: READ_COMMITTED [default READ_COMMITTED]
JDBC fetch size: 100
Pool: DataSourceConnectionProvider
Minimum pool size: undefined/unknown
Maximum pool size: undefined/unknown
2026-10-04T01:55:14.798+05:30 INFO 14110 --- [hexagonal] [ main] org.hibernate.orm.core : HHH000489: No JTA platform available (set 'hibernate.transaction.jta.platform' to enable JTA platform integration)
2026-10-04T01:55:14.854+05:30 INFO 14110 --- [hexagonal] [ main] j.LocalContainerEntityManagerFactoryBean : Initialized JPA EntityManagerFactory for persistence unit 'default'
2026-10-04T01:55:14.951+05:30 INFO 14110 --- [hexagonal] [ main] o.s.d.j.r.query.QueryEnhancerFactories : Hibernate is in classpath; If applicable, HQL parser will be used.
2026-10-04T01:55:15.106+05:30 WARN 14110 --- [hexagonal] [ main] JpaBaseConfiguration$JpaWebConfiguration : spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
2026-10-04T01:55:15.580+05:30 INFO 14110 --- [hexagonal] [ main] o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 44067 (http) with context path '/'
2026-10-04T01:55:15.588+05:30 INFO 14110 --- [hexagonal] [ main] c.a.h.app.HexagonalApplicationTest : Started HexagonalApplicationTest in 3.859 seconds (process running for 4.989)
Mockito is currently self-attaching to enable the inline-mock-maker. This will no longer work in future releases of the JDK. Please add Mockito as an agent to your build as described in Mockito's documentation: https://javadoc.io/doc/org.mockito/mockito-core/latest/org.mockito/org/mockito/Mockito.html#0.3
OpenJDK 64-Bit Server VM warning: Sharing is only supported for boot loader classes because bootstrap classpath has been appended
2026-10-04T01:55:16.239+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.a.c.c.C.[Tomcat].[localhost].[/] : Initializing Spring DispatcherServlet 'dispatcherServlet'
2026-10-04T01:55:16.240+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
2026-10-04T01:55:16.241+05:30 INFO 14110 --- [hexagonal] [o-auto-1-exec-1] o.s.web.servlet.DispatcherServlet : Completed initialization in 1 ms
Overdraft attempt: 409 CONFLICT
After deposit: 200 OK BalanceResponse[walletId=w1, balanceCents=1500]
After withdraw: 200 OK BalanceResponse[walletId=w1, balanceCents=1300]
Balance reloaded directly from WalletRepository: 1300
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 5.208 s -- in com.ankurm.hexagonal.app.HexagonalApplicationTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 8.913 s
[INFO] Finished at: 2026-10-04T01:55:16+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,17 @@
Captured by temporarily removing @EnableJpaRepositories and @EntityScan from
HexagonalApplication (leaving only @SpringBootApplication(scanBasePackages = "com.ankurm.hexagonal"))
and running `mvn -pl app test`. The real, unedited exception chain Spring produced:
Caused by: org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'jpaWalletRepositoryAdapter' defined in URL [jar:file:/root/.m2/repository/com/ankurm/hexagonal-persistence-adapter/1.0/hexagonal-persistence-adapter-1.0.jar!/com/ankurm/hexagonal/persistence/JpaWalletRepositoryAdapter.class]: Unsatisfied dependency expressed through constructor parameter 0: No qualifying bean of type 'com.ankurm.hexagonal.persistence.SpringDataWalletRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at org.springframework.beans.factory.support.ConstructorResolver.createArgumentArray(ConstructorResolver.java:804)
at org.springframework.beans.factory.support.ConstructorResolver.autowireConstructor(ConstructorResolver.java:240)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.autowireConstructor(AbstractAutowireCapableBeanFactory.java:1380)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.createBeanInstance(AbstractAutowireCapableBeanFactory.java:1219)
at org.springframework.beans.factory.support.AbstractAutowireCapableBeanFactory.doCreateBean(AbstractAutowireCapableBeanFactory.java:565)
Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: No qualifying bean of type 'com.ankurm.hexagonal.persistence.SpringDataWalletRepository' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {}
at org.springframework.beans.factory.support.DefaultListableBeanFactory.raiseNoMatchingBeanFound(DefaultListableBeanFactory.java:2304)
at org.springframework.beans.factory.support.DefaultListableBeanFactory.doResolveDependency(DefaultListableBeanFactory.java:1732)
Restoring @EnableJpaRepositories(basePackages = "com.ankurm.hexagonal.persistence") and
@EntityScan(basePackages = "com.ankurm.hexagonal.persistence") on HexagonalApplication and
re-running the exact same test produces the green run captured in 00-end-to-end-http.txt.
+73
View File
@@ -0,0 +1,73 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>hexagonal-app</artifactId>
<packaging>jar</packaging>
<!-- The only module that depends on both adapters, and the only place @SpringBootApplication
appears in this whole article. Wiring WalletService into both ports' beans happens here,
in plain @Bean methods, because the core itself carries no component-scanning annotation
for Spring to find. -->
<dependencies>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-core</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-persistence-adapter</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-web-adapter</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- TestRestTemplate itself survived Boot 4 (it's deprecated in favour of RestTestClient,
not removed), but it moved out of spring-boot-test into its own artifact, package
org.springframework.boot.resttestclient - confirmed by unzipping
spring-boot-resttestclient-4.1.1.jar, which is where TestRestTemplate.class and its
TestRestTemplateTestAutoConfiguration now live. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-resttestclient</artifactId>
<scope>test</scope>
</dependency>
<!-- TestRestTemplateTestAutoConfiguration's @ConditionalOnMissingBean reflectively
introspects a RestTemplateBuilder-returning @Bean method, so RestTemplateBuilder
itself (the production-side builder, confirmed in spring-boot-restclient-4.1.1.jar)
has to be resolvable even though nothing in this module calls it directly. Without
this, context startup fails with NoClassDefFoundError before a single test runs. -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-restclient</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring-boot.version}</version>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,39 @@
package com.ankurm.hexagonal.app;
import com.ankurm.hexagonal.WalletService;
import com.ankurm.hexagonal.ports.WalletRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.context.annotation.Bean;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* The composition root. Everything above this point in the article -- the domain, the ports,
* both adapters -- is wired together in exactly one place, and this is it.
* {@code WalletService} carries no {@code @Service} annotation, so without the {@code @Bean}
* method below, Spring would never construct it; the wiring is explicit, not discovered.
*
* <p>{@code scanBasePackages} covers {@code @Component}-style scanning, but Spring Data JPA's
* repository scan and JPA's entity scan each use their own default -- the package of this
* class, {@code com.ankurm.hexagonal.app} -- not {@code scanBasePackages}. That default is a
* sibling of {@code com.ankurm.hexagonal.persistence}, not an ancestor of it, so without the
* two annotations below Spring Data JPA silently finds zero repositories and the
* {@code JpaWalletRepositoryAdapter} bean fails to wire: see HexagonalApplicationTest's build
* log for the "No qualifying bean of type SpringDataWalletRepository" failure this caused.
*/
@SpringBootApplication(scanBasePackages = "com.ankurm.hexagonal")
@EnableJpaRepositories(basePackages = "com.ankurm.hexagonal.persistence")
@EntityScan(basePackages = "com.ankurm.hexagonal.persistence")
public class HexagonalApplication {
public static void main(String[] args) {
SpringApplication.run(HexagonalApplication.class, args);
}
@Bean
public WalletService walletService(WalletRepository walletRepository) {
return new WalletService(walletRepository);
}
}
@@ -0,0 +1,3 @@
spring.application.name=hexagonal
spring.datasource.url=jdbc:h2:mem:hexagonaldb;DB_CLOSE_DELAY=-1
spring.jpa.hibernate.ddl-auto=update
@@ -0,0 +1,80 @@
package com.ankurm.hexagonal.app;
import java.util.UUID;
import com.ankurm.hexagonal.domain.Wallet;
import com.ankurm.hexagonal.ports.WalletRepository;
import com.ankurm.hexagonal.web.WalletController.BalanceResponse;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.resttestclient.TestRestTemplate;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureTestRestTemplate;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import static org.assertj.core.api.Assertions.assertThat;
/**
* The whole stack, really wired, really running: a real embedded web server, a real H2
* database through the real persistence adapter, and HTTP requests that cross every
* boundary this article draws. If any of the wiring in {@link HexagonalApplication} were
* wrong, this is the test that would fail, not a unit test of any single class.
*
* <p>{@code @AutoConfigureTestRestTemplate} is required explicitly in Spring Boot 4 -- unlike
* Boot 3, a {@code @SpringBootTest(webEnvironment = RANDOM_PORT)} no longer auto-configures a
* {@code TestRestTemplate} bean on its own.
*/
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureTestRestTemplate
class HexagonalApplicationTest {
@Autowired
TestRestTemplate rest;
@Autowired
WalletRepository walletRepository;
@DynamicPropertySource
static void datasource(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url",
() -> "jdbc:h2:mem:" + UUID.randomUUID() + ";DB_CLOSE_DELAY=-1");
}
@Test
void depositThenWithdrawOverHttpEndsAtTheRightBalanceThroughTheRealDatabase() {
walletRepository.save(new Wallet("w1", 1000));
ResponseEntity<BalanceResponse> afterDeposit = rest.postForEntity(
"/wallets/w1/deposit?amountCents=500", null, BalanceResponse.class);
System.out.println("After deposit: " + afterDeposit.getStatusCode() + " " + afterDeposit.getBody());
ResponseEntity<BalanceResponse> afterWithdraw = rest.postForEntity(
"/wallets/w1/withdraw?amountCents=200", null, BalanceResponse.class);
System.out.println("After withdraw: " + afterWithdraw.getStatusCode() + " " + afterWithdraw.getBody());
assertThat(afterDeposit.getBody().balanceCents()).isEqualTo(1500);
assertThat(afterWithdraw.getBody().balanceCents()).isEqualTo(1300);
// Reload straight from the real repository bean -- not from the HTTP response -- to
// prove the number actually landed in H2, not just in the controller's return value.
long persisted = walletRepository.findById("w1").orElseThrow().balanceCents();
System.out.println("Balance reloaded directly from WalletRepository: " + persisted);
assertThat(persisted).isEqualTo(1300);
}
@Test
void withdrawingTooMuchReturns409() {
walletRepository.save(new Wallet("w2", 100));
ResponseEntity<String> response = rest.postForEntity(
"/wallets/w2/withdraw?amountCents=101", null, String.class);
System.out.println("Overdraft attempt: " + response.getStatusCode());
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CONFLICT);
}
}
@@ -0,0 +1,43 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- clean:3.2.0:clean (default-clean) @ hexagonal-core ---
[INFO] Deleting /home/claude/spring-boot-demo/hexagonal/core/target
[INFO]
[INFO] --- resources:3.3.1:resources (default-resources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/main/resources
[INFO]
[INFO] --- compiler:3.13.0:compile (default-compile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed source code.
[INFO] Compiling 7 source files with javac [debug parameters target 25] to target/classes
[INFO]
[INFO] --- resources:3.3.1:testResources (default-testResources) @ hexagonal-core ---
[INFO] skip non existing resourceDirectory /home/claude/spring-boot-demo/hexagonal/core/src/test/resources
[INFO]
[INFO] --- compiler:3.13.0:testCompile (default-testCompile) @ hexagonal-core ---
[INFO] Recompiling the module because of changed dependency.
[INFO] Compiling 2 source files with javac [debug parameters target 25] to target/test-classes
[INFO]
[INFO] --- surefire:3.5.4:test (default-test) @ hexagonal-core ---
[INFO] Using auto detected provider org.apache.maven.surefire.junitplatform.JUnitPlatformProvider
[INFO]
[INFO] -------------------------------------------------------
[INFO] T E S T S
[INFO] -------------------------------------------------------
[INFO] Running com.ankurm.hexagonal.WalletServiceTest
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.164 s -- in com.ankurm.hexagonal.WalletServiceTest
[INFO]
[INFO] Results:
[INFO]
[INFO] Tests run: 4, Failures: 0, Errors: 0, Skipped: 0
[INFO]
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 2.794 s
[INFO] Finished at: 2026-10-04T01:53:57+05:30
[INFO] ------------------------------------------------------------------------
@@ -0,0 +1,26 @@
[INFO] Scanning for projects...
[INFO]
[INFO] ---------------------< com.ankurm:hexagonal-core >----------------------
[INFO] Building hexagonal-core 1.0
[INFO] from pom.xml
[INFO] --------------------------------[ jar ]---------------------------------
[INFO]
[INFO] --- dependency:3.7.0:tree (default-cli) @ hexagonal-core ---
[INFO] com.ankurm:hexagonal-core:jar:1.0
[INFO] +- org.junit.jupiter:junit-jupiter:jar:6.0.3:test
[INFO] | +- org.junit.jupiter:junit-jupiter-api:jar:6.0.3:test
[INFO] | | +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO] | | +- org.junit.platform:junit-platform-commons:jar:6.0.3:test
[INFO] | | +- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO] | | \- org.jspecify:jspecify:jar:1.0.1:test
[INFO] | +- org.junit.jupiter:junit-jupiter-params:jar:6.0.3:test
[INFO] | \- org.junit.jupiter:junit-jupiter-engine:jar:6.0.3:test
[INFO] | \- org.junit.platform:junit-platform-engine:jar:6.0.3:test
[INFO] \- org.assertj:assertj-core:jar:3.27.7:test
[INFO] \- net.bytebuddy:byte-buddy:jar:1.18.11:test
[INFO] ------------------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] ------------------------------------------------------------------------
[INFO] Total time: 1.206 s
[INFO] Finished at: 2026-10-04T01:54:04+05:30
[INFO] ------------------------------------------------------------------------
+42
View File
@@ -0,0 +1,42 @@
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>hexagonal-parent</artifactId>
<version>1.0</version>
</parent>
<artifactId>hexagonal-core</artifactId>
<packaging>jar</packaging>
<!-- No Spring dependency anywhere in this file, not even spring-boot-starter-test. That's
not a style choice being asked of contributors; it's what lets this one module's tests
run in milliseconds with no application context - see output/00-core-tests-no-spring.txt -->
<!-- Versions for both come from the parent's imported spring-boot-dependencies BOM (the
same BOM every other module in this repo uses), which is a version source, not a
runtime dependency - importing it in dependencyManagement pulls in nothing until a
module actually declares one of the artifacts it manages. -->
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.5.4</version>
</plugin>
</plugins>
</build>
</project>

Some files were not shown because too many files have changed in this diff Show More