Compare commits
11
Commits
b4b43b94bf
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
008211f2fa | ||
|
|
b9388053c4 | ||
|
|
4370ac7fdf | ||
|
|
97f946136b | ||
|
|
5e4d481c02 | ||
|
|
5851521420 | ||
|
|
f736108067 | ||
|
|
83d245e726 | ||
|
|
fd6d0fa1e7 | ||
|
|
ae681f85c4 | ||
|
|
804c33f0d5 |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
target/
|
||||||
@@ -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.
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>);
|
||||||
Executable
+69
@@ -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
|
||||||
@@ -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])
|
||||||
@@ -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>
|
||||||
+12
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+51
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+39
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
target/
|
||||||
|
cap/app.log
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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"}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
Executable
+36
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.gradle/
|
||||||
|
build/
|
||||||
@@ -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`.
|
||||||
@@ -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() }
|
||||||
@@ -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" }
|
||||||
BIN
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
|
||||||
+248
@@ -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" "$@"
|
||||||
Vendored
+112
@@ -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%
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Executable
+60
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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] ------------------------------------------------------------------------
|
||||||
@@ -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
Reference in New Issue
Block a user