Compare commits
14
Commits
3e2029ab3a
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
008211f2fa | ||
|
|
b9388053c4 | ||
|
|
4370ac7fdf | ||
|
|
97f946136b | ||
|
|
5e4d481c02 | ||
|
|
5851521420 | ||
|
|
f736108067 | ||
|
|
83d245e726 | ||
|
|
fd6d0fa1e7 | ||
|
|
ae681f85c4 | ||
|
|
804c33f0d5 | ||
|
|
b4b43b94bf | ||
|
|
e4f927d7af | ||
|
|
acd63e658b |
@@ -9,6 +9,7 @@ files.
|
||||
|
||||
| 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 |
|
||||
| [`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 |
|
||||
@@ -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 |
|
||||
| [`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 |
|
||||
| [`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
|
||||
collapsible sections inside the articles themselves, and their captured output sits in a top-level `output/`
|
||||
directory instead of `docs/output/`.
|
||||
`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. Where their captured output sits is not
|
||||
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
|
||||
`<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,16 @@
|
||||
# boot42-preview
|
||||
|
||||
Companion module for the ankurm.com article *Spring Boot 4.2 Preview: AMQP 1.0, Buildpack Image Cache and What to Test Now*.
|
||||
Everything runs against **4.2.0-M2** (a milestone, not GA) and, where a comparison matters, against **4.1.1** (GA).
|
||||
|
||||
| Path | What it is |
|
||||
|---|---|
|
||||
| `pom.xml` / `pom-41.xml` | the same project on 4.2.0-M2 and on 4.1.1 (a `${revision}` parent does not resolve from a repository, hence two files) |
|
||||
| `src/test/.../ForwardedHeadersTest` | what the container believes under each forwarded-header strategy |
|
||||
| `src/test/.../BindingOrderTest` | Set and Map iteration order from a properties file |
|
||||
| `src/broken/UsesRestTemplate.java` | not built; compiled by the facts script to capture the deprecation warnings |
|
||||
| `src/main/.../RestClientReplacement` | the replacement, compiled clean |
|
||||
| `examples/build-image-cache-fragment.xml` | **not executed** (needs Docker and a registry) |
|
||||
| `output/` | every transcript quoted in the article; `./scripts/run-all.sh` regenerates them |
|
||||
|
||||
There is deliberately no `docs/` folder: the explanations live in the article.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!-- NOT EXECUTED: needs a Docker daemon and a registry. Element names come from the setters javap shows in
|
||||
output/04-buildpack-cache-classes.txt (CacheInfo.setImage, ImageCacheInfo.setName). -->
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<image>
|
||||
<buildCache>
|
||||
<image>
|
||||
<name>registry.example.com/team/demo-build-cache:latest</name>
|
||||
</image>
|
||||
</buildCache>
|
||||
</image>
|
||||
</configuration>
|
||||
</plugin>
|
||||
@@ -0,0 +1,10 @@
|
||||
# 01-forwarded-headers (Spring Boot 4.1.1, JDK 25)
|
||||
none/x-forwarded scheme=http secure=false host=localhost port=<random>
|
||||
none/rfc7239 scheme=http secure=false host=localhost port=<random>
|
||||
native/x-forwarded scheme=https secure=true host=shop.example.com port=443
|
||||
native/rfc7239 scheme=http secure=false host=localhost port=<random>
|
||||
framework/x-forwarded scheme=https secure=true host=shop.example.com port=443
|
||||
framework/rfc7239 scheme=https secure=true host=shop.example.com port=443
|
||||
framework+standard/x-forwarded scheme=https secure=true host=shop.example.com port=443
|
||||
framework+standard/rfc7239 scheme=https secure=true host=shop.example.com port=443
|
||||
header-format=forwarded -> started (property ignored)
|
||||
@@ -0,0 +1,10 @@
|
||||
# 01-forwarded-headers (Spring Boot 4.2.0-M2, JDK 25)
|
||||
none/x-forwarded scheme=http secure=false host=localhost port=<random>
|
||||
none/rfc7239 scheme=http secure=false host=localhost port=<random>
|
||||
native/x-forwarded scheme=https secure=true host=shop.example.com port=443
|
||||
native/rfc7239 scheme=http secure=false host=localhost port=<random>
|
||||
framework/x-forwarded scheme=https secure=true host=shop.example.com port=443
|
||||
framework/rfc7239 scheme=http secure=false host=localhost port=<random>
|
||||
framework+standard/x-forwarded scheme=http secure=false host=localhost port=<random>
|
||||
framework+standard/rfc7239 scheme=https secure=true host=shop.example.com port=443
|
||||
header-format=forwarded -> IllegalArgumentException: No enum constant org.springframework.boot.webmvc.autoconfigure.WebMvcProperties.HeaderFormat.forwarded
|
||||
@@ -0,0 +1,6 @@
|
||||
# 02-set-and-map-binding-order (Spring Boot 4.1.1, JDK 25)
|
||||
file order : zulu, alpha, mike, bravo, yankee, charlie
|
||||
Set class : java.util.LinkedHashSet
|
||||
Set order : [zulu, alpha, mike, bravo, yankee, charlie]
|
||||
Map class : java.util.LinkedHashMap
|
||||
Map key order : [zulu, alpha, mike, bravo, yankee, charlie]
|
||||
@@ -0,0 +1,6 @@
|
||||
# 02-set-and-map-binding-order (Spring Boot 4.2.0-M2, JDK 25)
|
||||
file order : zulu, alpha, mike, bravo, yankee, charlie
|
||||
Set class : java.util.LinkedHashSet
|
||||
Set order : [zulu, alpha, mike, bravo, yankee, charlie]
|
||||
Map class : java.util.LinkedHashMap
|
||||
Map key order : [zulu, alpha, mike, bravo, yankee, charlie]
|
||||
@@ -0,0 +1,24 @@
|
||||
# 03-amqp-starters (POM descriptions and contents straight from Maven Central)
|
||||
spring-boot-starter-amqp 4.1.1 HTTP 200 "Starter for using Spring AMQP and Rabbit MQ" -> spring-boot-amqp
|
||||
spring-boot-starter-amqp 4.2.0-M2 HTTP 200 "Starter for generic AMQP 1.0 support" -> spring-boot-amqp
|
||||
spring-boot-starter-rabbitmq 4.1.1 HTTP 404 (does not exist)
|
||||
spring-boot-starter-rabbitmq 4.2.0-M2 HTTP 200 "Starter for using RabbitMQ messaging and streaming broker" -> spring-boot-rabbitmq
|
||||
spring-boot-starter-amqp-rabbitmq 4.1.1 HTTP 404 (does not exist)
|
||||
spring-boot-starter-amqp-rabbitmq 4.2.0-M2 HTTP 200 "Starter for using Spring AMQP with Rabbit MQ over AMQP 1.0 protocol" -> spring-boot-amqp-rabbitmq
|
||||
|
||||
# auto-configuration each module registers (AutoConfiguration.imports)
|
||||
--- spring-boot-amqp 4.1.1
|
||||
org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration
|
||||
org.springframework.boot.amqp.autoconfigure.health.RabbitHealthContributorAutoConfiguration
|
||||
org.springframework.boot.amqp.autoconfigure.metrics.RabbitMetricsAutoConfiguration
|
||||
--- spring-boot-amqp 4.2.0-M2
|
||||
org.springframework.boot.amqp.autoconfigure.AmqpAutoConfiguration
|
||||
|
||||
--- spring-boot-rabbitmq 4.2.0-M2
|
||||
org.springframework.boot.rabbitmq.autoconfigure.RabbitAutoConfiguration
|
||||
org.springframework.boot.rabbitmq.autoconfigure.health.RabbitHealthContributorAutoConfiguration
|
||||
org.springframework.boot.rabbitmq.autoconfigure.metrics.RabbitMetricsAutoConfiguration
|
||||
--- spring-boot-amqp-rabbitmq 4.2.0-M2
|
||||
org.springframework.boot.amqp.rabbitmq.autoconfigure.AmqpRabbitAutoConfiguration
|
||||
org.springframework.boot.amqp.rabbitmq.autoconfigure.health.AmqpRabbitHealthContributorAutoConfiguration
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# 04-buildpack-cache-classes (javap, spring-boot-buildpack-platform and spring-boot-maven-plugin)
|
||||
--- buildpack-platform 4.1.1: classes matching Cache
|
||||
Cache$Bind.class
|
||||
Cache$Format.class
|
||||
Cache$Volume.class
|
||||
Cache.class
|
||||
--- buildpack-platform 4.2.0-M2: classes matching Cache
|
||||
Cache.class
|
||||
ImageCache.class
|
||||
LocalCache$Bind.class
|
||||
LocalCache$Volume.class
|
||||
LocalCache.class
|
||||
--- javap Cache (4.2.0-M2)
|
||||
Compiled from "Cache.java"
|
||||
public interface Cache {
|
||||
public default LocalCache$Volume getVolume();
|
||||
public default LocalCache$Bind getBind();
|
||||
public default ImageCache getImage();
|
||||
public static LocalCache volume(java.lang.String);
|
||||
public static LocalCache volume(VolumeName);
|
||||
public static LocalCache bind(java.lang.String);
|
||||
public static ImageCache image(java.lang.String);
|
||||
}
|
||||
--- javap org.springframework.boot.maven.CacheInfo (4.2.0-M2)
|
||||
Compiled from "CacheInfo.java"
|
||||
public class CacheInfo {
|
||||
public CacheInfo();
|
||||
public void setVolume(LocalCacheInfo$VolumeCacheInfo);
|
||||
public void setBind(LocalCacheInfo$BindCacheInfo);
|
||||
public void setImage(CacheInfo$ImageCacheInfo);
|
||||
Cache asCache();
|
||||
static CacheInfo fromVolume(LocalCacheInfo$VolumeCacheInfo);
|
||||
static CacheInfo fromBind(LocalCacheInfo$BindCacheInfo);
|
||||
static CacheInfo fromImage(CacheInfo$ImageCacheInfo);
|
||||
}
|
||||
--- javap org.springframework.boot.maven.LocalCacheInfo (4.2.0-M2)
|
||||
Compiled from "LocalCacheInfo.java"
|
||||
public class LocalCacheInfo {
|
||||
public LocalCacheInfo();
|
||||
LocalCacheInfo(LocalCache);
|
||||
public void setVolume(LocalCacheInfo$VolumeCacheInfo);
|
||||
public void setBind(LocalCacheInfo$BindCacheInfo);
|
||||
LocalCache asCache();
|
||||
static LocalCacheInfo fromVolume(LocalCacheInfo$VolumeCacheInfo);
|
||||
static LocalCacheInfo fromBind(LocalCacheInfo$BindCacheInfo);
|
||||
}
|
||||
--- javap -p Image, fields mentioning cache/workspace (4.2.0-M2)
|
||||
LocalCacheInfo buildWorkspace;
|
||||
CacheInfo buildCache;
|
||||
LocalCacheInfo launchCache;
|
||||
@@ -0,0 +1,22 @@
|
||||
# 05-resttemplate-deprecations (javac -Xlint:deprecation on src/broken/UsesRestTemplate.java)
|
||||
--- Spring Boot 4.1.1
|
||||
--- Spring Boot 4.2.0-M2
|
||||
UsesRestTemplate.java:8: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
|
||||
RestTemplate viaBuilder(RestTemplateBuilder builder) {
|
||||
^
|
||||
UsesRestTemplate.java:8: warning: [removal] RestTemplateBuilder in org.springframework.boot.restclient has been deprecated and marked for removal
|
||||
RestTemplate viaBuilder(RestTemplateBuilder builder) {
|
||||
^
|
||||
UsesRestTemplate.java:12: warning: [removal] TestRestTemplate in org.springframework.boot.resttestclient has been deprecated and marked for removal
|
||||
TestRestTemplate viaTest(TestRestTemplate t) {
|
||||
^
|
||||
UsesRestTemplate.java:12: warning: [removal] TestRestTemplate in org.springframework.boot.resttestclient has been deprecated and marked for removal
|
||||
TestRestTemplate viaTest(TestRestTemplate t) {
|
||||
^
|
||||
UsesRestTemplate.java:16: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
|
||||
RestTemplate plain() {
|
||||
^
|
||||
UsesRestTemplate.java:17: warning: [removal] RestTemplate in org.springframework.web.client has been deprecated and marked for removal
|
||||
return new RestTemplate();
|
||||
^
|
||||
6 warnings
|
||||
@@ -0,0 +1,2 @@
|
||||
# 06-restclient-replacement (javac -Xlint:deprecation on the RestClient version, Spring Boot 4.2.0-M2)
|
||||
javac finished; lines above this one are warnings (none means a clean compile)
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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>boot42-preview-on-41</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>boot42-preview</name>
|
||||
<description>Spring Boot 4.2 milestone preview: what to test now, run against 4.2.0-M2 and 4.1.1</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<boot.label>4.1.1</boot.label>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<boot.label>${boot.label}</boot.label>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,57 @@
|
||||
<?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.2.0-M2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>boot42-preview</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>boot42-preview</name>
|
||||
<description>Spring Boot 4.2 milestone preview: what to test now, run against 4.2.0-M2 and 4.1.1</description>
|
||||
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
<boot.label>4.2.0-M2</boot.label>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-restclient</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-webmvc-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<systemPropertyVariables>
|
||||
<boot.label>${boot.label}</boot.label>
|
||||
</systemPropertyVariables>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
Executable
+78
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Facts read from jars and POMs on Maven Central: nothing here needs a running application.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
M="$HOME/.m2/repository/org/springframework/boot"
|
||||
CEN=https://repo1.maven.org/maven2/org/springframework/boot
|
||||
W=$(mktemp -d)
|
||||
for v in 4.1.1 4.2.0-M2; do for a in spring-boot-maven-plugin spring-boot-buildpack-platform spring-boot-amqp spring-boot-amqp-rabbitmq spring-boot-rabbitmq; do
|
||||
mvn -B -q dependency:get -Dartifact=org.springframework.boot:$a:$v >/dev/null 2>&1 || true
|
||||
done; done
|
||||
|
||||
{
|
||||
echo "# 03-amqp-starters (POM descriptions and contents straight from Maven Central)"
|
||||
for a in spring-boot-starter-amqp spring-boot-starter-rabbitmq spring-boot-starter-amqp-rabbitmq; do
|
||||
for v in 4.1.1 4.2.0-M2; do
|
||||
for try in 1 2 3 4 5; do
|
||||
code=$(curl -s -o "$W/p.xml" -w '%{http_code}' "$CEN/$a/$v/$a-$v.pom")
|
||||
[ "$code" = 429 ] || break
|
||||
sleep $((try * 3))
|
||||
done
|
||||
if [ "$code" = 200 ]; then
|
||||
d=$(grep -m1 '<description>' "$W/p.xml" | sed 's/ *<[^>]*>//g')
|
||||
dep=$(grep -E '<artifactId>spring-boot-(amqp|rabbitmq|amqp-rabbitmq)</artifactId>' "$W/p.xml" | sed 's/ *<[^>]*>//g' | paste -sd, -)
|
||||
printf '%-34s %-10s HTTP %s "%s" -> %s\n' "$a" "$v" "$code" "$d" "$dep"
|
||||
else
|
||||
printf '%-34s %-10s HTTP %s (does not exist)\n' "$a" "$v" "$code"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo
|
||||
echo "# auto-configuration each module registers (AutoConfiguration.imports)"
|
||||
for a in spring-boot-amqp spring-boot-rabbitmq spring-boot-amqp-rabbitmq; do
|
||||
for v in 4.1.1 4.2.0-M2; do
|
||||
j="$M/$a/$v/$a-$v.jar"
|
||||
if [ -f "$j" ]; then
|
||||
echo "--- $a $v"; unzip -p "$j" META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 2>/dev/null | sed 's/^/ /' || true; echo
|
||||
fi
|
||||
done
|
||||
done
|
||||
} > output/03-amqp-starters.txt
|
||||
|
||||
{
|
||||
echo "# 04-buildpack-cache-classes (javap, spring-boot-buildpack-platform and spring-boot-maven-plugin)"
|
||||
for v in 4.1.1 4.2.0-M2; do
|
||||
echo "--- buildpack-platform $v: classes matching Cache"
|
||||
unzip -l "$M/spring-boot-buildpack-platform/$v/spring-boot-buildpack-platform-$v.jar" | awk '{print $4}' | grep -E 'build/.*Cache' | sed 's#.*/build/# #'
|
||||
done
|
||||
mkdir -p "$W/bp"
|
||||
unzip -q -o "$M/spring-boot-buildpack-platform/4.2.0-M2/spring-boot-buildpack-platform-4.2.0-M2.jar" 'org/springframework/boot/buildpack/platform/build/*Cache*' -d "$W/bp"
|
||||
unzip -q -o "$M/spring-boot-maven-plugin/4.2.0-M2/spring-boot-maven-plugin-4.2.0-M2.jar" 'org/springframework/boot/maven/*' -d "$W/bp"
|
||||
echo "--- javap Cache (4.2.0-M2)"
|
||||
javap -cp "$W/bp" org.springframework.boot.buildpack.platform.build.Cache 2>/dev/null | sed 's/org.springframework.boot.buildpack.platform.build.//g; s/org.springframework.boot.buildpack.platform.docker.type.//g'
|
||||
for c in 'org.springframework.boot.maven.CacheInfo' 'org.springframework.boot.maven.LocalCacheInfo'; do
|
||||
echo "--- javap $c (4.2.0-M2)"
|
||||
javap -cp "$W/bp" "$c" 2>/dev/null | sed 's/org.springframework.boot.maven.//g; s/org.springframework.boot.buildpack.platform.build.//g'
|
||||
done
|
||||
echo "--- javap -p Image, fields mentioning cache/workspace (4.2.0-M2)"
|
||||
javap -p -cp "$W/bp" org.springframework.boot.maven.Image 2>/dev/null | grep -E '(Cache|buildWorkspace)[a-zA-Z]* [a-zA-Z]+;$' | sed 's/org.springframework.boot.maven.//g'
|
||||
} > output/04-buildpack-cache-classes.txt
|
||||
|
||||
{
|
||||
echo "# 05-resttemplate-deprecations (javac -Xlint:deprecation on src/broken/UsesRestTemplate.java)"
|
||||
for pom in pom-41.xml pom.xml; do
|
||||
mvn -B -q -f $pom dependency:build-classpath -Dmdep.outputFile="$W/cp.txt" >/dev/null 2>&1
|
||||
label=$([ $pom = pom.xml ] && echo 4.2.0-M2 || echo 4.1.1)
|
||||
echo "--- Spring Boot $label"
|
||||
mkdir -p "$W/out-$label"
|
||||
javac -Xlint:deprecation -d "$W/out-$label" -cp "$(cat "$W/cp.txt")" src/broken/UsesRestTemplate.java 2>&1 | grep -v JAVA_TOOL | sed 's#^src/broken/##' || true
|
||||
done
|
||||
} > output/05-resttemplate-deprecations.txt
|
||||
|
||||
{
|
||||
echo "# 06-restclient-replacement (javac -Xlint:deprecation on the RestClient version, Spring Boot 4.2.0-M2)"
|
||||
mvn -B -q dependency:build-classpath -Dmdep.outputFile="$W/cp.txt" >/dev/null 2>&1
|
||||
mkdir -p "$W/out-rc"
|
||||
javac -Xlint:deprecation -Xlint:removal -d "$W/out-rc" -cp "$(cat "$W/cp.txt")" src/main/java/com/ankurm/boot42/RestClientReplacement.java 2>&1 | grep -v JAVA_TOOL | sed 's#^src/main/java/com/ankurm/boot42/##' || true
|
||||
echo "javac finished; lines above this one are warnings (none means a clean compile)"
|
||||
} > output/06-restclient-replacement.txt
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file under output/. Needs JDK 25 and Maven 3.9 with network access to Maven Central.
|
||||
# ./scripts/run-all.sh
|
||||
# 01-02 come from the test suite (run on 4.2.0-M2, then on 4.1.1 via pom-41.xml, which writes the *-on-4.1.1 files).
|
||||
# 03-06 come from jars, POMs and javac. Port numbers are random, so the transcripts print "<random>".
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
mvn -B test
|
||||
mvn -B -f pom-41.xml test
|
||||
./scripts/capture-facts.sh
|
||||
ls -1 output
|
||||
@@ -0,0 +1,19 @@
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.boot.resttestclient.TestRestTemplate;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/** Not part of the build: compiled by scripts/capture-facts.sh so the compiler's own words are committed. */
|
||||
class UsesRestTemplate {
|
||||
|
||||
RestTemplate viaBuilder(RestTemplateBuilder builder) {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
TestRestTemplate viaTest(TestRestTemplate t) {
|
||||
return t;
|
||||
}
|
||||
|
||||
RestTemplate plain() {
|
||||
return new RestTemplate();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
/** Minimal application. Every experiment in this module lives in src/test. */
|
||||
@SpringBootApplication
|
||||
@org.springframework.boot.context.properties.EnableConfigurationProperties(OrderProperties.class)
|
||||
public class Boot42PreviewApplication {
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/** A Set and a Map bound from a file where the keys are deliberately not alphabetical. */
|
||||
@ConfigurationProperties("app")
|
||||
public record OrderProperties(Set<String> regions, Map<String, Integer> limits) {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** What the deprecated RestTemplateBuilder code turns into: an injected RestClient.Builder. */
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
class RestClientReplacement {
|
||||
|
||||
@Bean
|
||||
RestClient inventoryClient(RestClient.Builder builder) {
|
||||
return builder.baseUrl("http://inventory.internal").build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/** Echoes what the servlet container believes about the request, after any forwarded-header handling. */
|
||||
@RestController
|
||||
class WhoAmIController {
|
||||
|
||||
@GetMapping("/whoami")
|
||||
Map<String, Object> whoAmI(HttpServletRequest request) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("scheme", request.getScheme());
|
||||
m.put("secure", request.isSecure());
|
||||
m.put("serverName", request.getServerName());
|
||||
m.put("serverPort", request.getServerPort());
|
||||
m.put("remoteAddr", request.getRemoteAddr());
|
||||
return m;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
app.regions=zulu,alpha,mike,bravo,yankee,charlie
|
||||
app.limits.zulu=1
|
||||
app.limits.alpha=2
|
||||
app.limits.mike=3
|
||||
app.limits.bravo=4
|
||||
app.limits.yankee=5
|
||||
app.limits.charlie=6
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/** 4.2.0-M1: Sets and Maps bind consistently and keep the order of the configuration file. */
|
||||
class BindingOrderTest {
|
||||
|
||||
@Test
|
||||
void recordsIterationOrder() {
|
||||
Transcript t = new Transcript("02-set-and-map-binding-order");
|
||||
try (ConfigurableApplicationContext ctx = BootRun.start("spring.config.name=order")) {
|
||||
OrderProperties p = ctx.getBean(OrderProperties.class);
|
||||
t.line("file order : zulu, alpha, mike, bravo, yankee, charlie");
|
||||
t.line("Set class : " + p.regions().getClass().getName());
|
||||
t.line("Set order : " + new ArrayList<>(p.regions()));
|
||||
t.line("Map class : " + p.limits().getClass().getName());
|
||||
t.line("Map key order : " + new ArrayList<>(p.limits().keySet()));
|
||||
t.write();
|
||||
assertThat(p.regions()).hasSize(6);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Starts the real application with the given properties on a random port. */
|
||||
final class BootRun {
|
||||
|
||||
private BootRun() {
|
||||
}
|
||||
|
||||
static ConfigurableApplicationContext start(String... props) {
|
||||
return new SpringApplicationBuilder(Boot42PreviewApplication.class)
|
||||
.web(WebApplicationType.SERVLET)
|
||||
.properties(props)
|
||||
.properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN")
|
||||
.run();
|
||||
}
|
||||
|
||||
static RestClient client(ConfigurableApplicationContext ctx) {
|
||||
Environment env = ctx.getEnvironment();
|
||||
return RestClient.create("http://localhost:" + env.getProperty("local.server.port"));
|
||||
}
|
||||
|
||||
static Map<?, ?> whoAmI(ConfigurableApplicationContext ctx, Map<String, String> headers) {
|
||||
return client(ctx).get().uri("/whoami").headers(h -> headers.forEach(h::add)).retrieve().body(Map.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
|
||||
/**
|
||||
* 4.2.0-M1: "When server.forward-headers-strategy=FRAMEWORK is selected, X-Forwarded-* headers are used by
|
||||
* default." Sends both header styles under each strategy and records what the container believes.
|
||||
*/
|
||||
class ForwardedHeadersTest {
|
||||
|
||||
private static final Map<String, String> X_FORWARDED = Map.of("X-Forwarded-Proto", "https",
|
||||
"X-Forwarded-Host", "shop.example.com", "X-Forwarded-Port", "443");
|
||||
private static final Map<String, String> RFC_7239 = Map.of("Forwarded", "proto=https;host=shop.example.com");
|
||||
|
||||
private static final Map<String, Map<String, String>> HEADERS = new LinkedHashMap<>();
|
||||
static {
|
||||
HEADERS.put("x-forwarded", X_FORWARDED);
|
||||
HEADERS.put("rfc7239", RFC_7239);
|
||||
}
|
||||
|
||||
@Test
|
||||
void recordsBehaviourPerStrategy() {
|
||||
Transcript t = new Transcript("01-forwarded-headers");
|
||||
Map<String, Map<?, ?>> results = new LinkedHashMap<>();
|
||||
for (String strategy : new String[] { "none", "native", "framework" }) {
|
||||
for (var e : HEADERS.entrySet()) {
|
||||
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=" + strategy)) {
|
||||
Map<?, ?> seen = BootRun.whoAmI(ctx, e.getValue());
|
||||
String key = strategy + "/" + e.getKey();
|
||||
results.put(key, seen);
|
||||
t.line(String.format("%-22s scheme=%s secure=%s host=%s port=%s", key, seen.get("scheme"),
|
||||
seen.get("secure"), seen.get("serverName"), seen.get("serverPort").toString().equals("443") ? "443" : "<random>"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// the new 4.2 knob: which header family the FRAMEWORK strategy reads
|
||||
for (var e : HEADERS.entrySet()) {
|
||||
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=framework",
|
||||
"spring.mvc.forwarded-headers.header-format=standard")) {
|
||||
Map<?, ?> seen = BootRun.whoAmI(ctx, e.getValue());
|
||||
t.line(String.format("%-22s scheme=%s secure=%s host=%s port=%s", "framework+standard/" + e.getKey(),
|
||||
seen.get("scheme"), seen.get("secure"), seen.get("serverName"), seen.get("serverPort").toString().equals("443") ? "443" : "<random>"));
|
||||
}
|
||||
}
|
||||
// my first guess at the value was "forwarded"; record what the binder says about it
|
||||
try (ConfigurableApplicationContext ctx = BootRun.start("server.forward-headers-strategy=framework",
|
||||
"spring.mvc.forwarded-headers.header-format=forwarded")) {
|
||||
t.line("header-format=forwarded -> started (property ignored)");
|
||||
}
|
||||
catch (Exception ex) {
|
||||
Throwable root = ex;
|
||||
while (root.getCause() != null) {
|
||||
root = root.getCause();
|
||||
}
|
||||
t.line("header-format=forwarded -> " + root.getClass().getSimpleName() + ": " + root.getMessage());
|
||||
}
|
||||
t.write();
|
||||
// a strategy of "none" must never trust either header
|
||||
assertThat(results.get("none/x-forwarded").get("scheme")).isEqualTo("http");
|
||||
assertThat(results.get("none/rfc7239").get("scheme")).isEqualTo("http");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.ankurm.boot42;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Collects lines and writes output/NN-name.txt. The test asserts the same values it writes. */
|
||||
final class Transcript {
|
||||
|
||||
private final List<String> lines = new ArrayList<>();
|
||||
private final Path file;
|
||||
|
||||
Transcript(String name) {
|
||||
String v = System.getProperty("boot.label", "4.2.0-M2");
|
||||
String suffix = v.equals("4.2.0-M2") ? "" : "-on-" + v;
|
||||
this.file = Path.of("output", name + suffix + ".txt");
|
||||
line("# " + name + " (Spring Boot " + v + ", JDK " + Runtime.version().feature() + ")");
|
||||
}
|
||||
|
||||
Transcript line(String s) {
|
||||
lines.add(s);
|
||||
return this;
|
||||
}
|
||||
|
||||
void write() {
|
||||
try {
|
||||
Files.createDirectories(file.getParent());
|
||||
Files.write(file, lines);
|
||||
}
|
||||
catch (IOException ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user