Add boot4-migration module: one Boot 3.5.16 service migrated to 4.1.2, with the real compile, start-up, silent and test failures captured at each step
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_017ic4b4VQVEJWetTT1dYzyJ
This commit is contained in:
@@ -50,6 +50,7 @@ files.
|
||||
| [`crud-mysql/`](crud-mysql) | [Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/) | a complete book API on a real MySQL 8.4.6 server with Flyway owning the schema and Hibernate set to `validate`, `ProblemDetail` errors with per-field validation messages, a duplicate ISBN caught by the database's unique key and mapped to `409` on create and on update, MySQL's own `Duplicate entry` message, and the SQL behind every request |
|
||||
| [`boot42-rc1-checklist/`](boot42-rc1-checklist) | [Spring Framework 7.1 and Spring Boot 4.2: What's Coming and How to Prepare](https://ankurm.com/spring-boot-4-2-spring-framework-7-1-upgrade-checklist/) | each 4.2 milestone change run on 4.2.0-RC1 and on 4.1.2: `@DefaultValue` placeholders resolved, five new Kafka admin keys that 4.1.2 silently ignores, embedded LDAP SSL failing without a bundle, the properties migrator's real report (and the removed key it misses), and the `RestTemplate` removal warnings from the compiler |
|
||||
| [`boot42-otel/`](boot42-otel) | [Spring Boot 4.2 OpenTelemetry: Semantic Conventions, Shared OTLP Settings and MDC Keys](https://ankurm.com/spring-boot-4-2-opentelemetry-semantic-conventions-shared-otlp-mdc/) | one request measured under each `management.observations.conventions` value (the documented `open-telemetry` fails to start on RC1, `opentelemetry` works), meter, span and Prometheus names before and after, `management.opentelemetry.otlp.*` verified against a local OTLP receiver (paths, merged headers, gzip, per-signal overrides), custom MDC keys, and the LongTaskTimer that is no longer on by default |
|
||||
| [`boot4-migration/`](boot4-migration) | [Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/) | one Boot 3.5.16 service migrated to 4.1.2 with the real failures captured at each step: 12 javac errors from a version bump, a start-up failure from a missing `spring-boot-starter-restclient`, Flyway silently not running without `spring-boot-starter-flyway`, removed `logging.file.*` keys silently ignored, Jackson 3 sorting bean properties, liveness/readiness groups appearing, and a `@Mock` field that is `null` without `MockitoExtension` |
|
||||
|
||||
`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
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# boot4-migration
|
||||
|
||||
Companion module for the ankurm.com post
|
||||
[Spring Boot 3 to 4 Migration Guide (Updated for 4.1 and 4.2)](https://ankurm.com/spring-boot-3-to-4-migration-guide/).
|
||||
|
||||
One small service (Web MVC, Security, Actuator, JDBC + Flyway, AOP aspect, `RestClient.Builder`,
|
||||
a Jackson serializer, three tests) in two forms:
|
||||
|
||||
| Directory | What it is | Built by |
|
||||
|---|---|---|
|
||||
| `before/` | the service on Spring Boot 3.5.16 | never by a normal build; `scripts/run-all.sh` copies it to a scratch dir |
|
||||
| `after/` | the same service migrated to Spring Boot 4.1.2 | `cd after && mvn verify` |
|
||||
|
||||
`run-all.sh` builds `before/` as written, then bumps only the parent version and records what
|
||||
breaks at each step: the build error, the javac errors, the start-up failure report, the
|
||||
things that start fine but behave differently, and the test failures. Every file in `output/` is
|
||||
a real transcript from that script (JDK 25.0.5, Maven 3.9).
|
||||
|
||||
```bash
|
||||
export JAVA_HOME=/path/to/jdk-25
|
||||
./scripts/run-all.sh # 15-25 minutes on a shared 2-vCPU box; needs Maven Central
|
||||
```
|
||||
|
||||
| Output | Shows |
|
||||
|---|---|
|
||||
| `01-before-boot35.txt` | `before/` builds and runs on 3.5.16 (the baseline every other file is compared with) |
|
||||
| `02-bump-only-compile-errors.txt` | only the parent version changed: Maven POM error, then 12 javac errors |
|
||||
| `03-compiles-but-will-not-start.txt` | imports ported, starters not: compiles, fails at start-up (no `RestClient.Builder`) |
|
||||
| `04-starts-but-silently-wrong.txt` | starts, answers 200, but Flyway never ran, logs never rotate |
|
||||
| `05-properties-migrator.txt` | what `spring-boot-properties-migrator` does and does not report |
|
||||
| `06-after-boot41.txt` | `after/`: 8 tests pass, same endpoints, new health groups |
|
||||
| `07-jackson-property-order.txt` | bean property order on Jackson 2, Jackson 3, and `use-jackson2-defaults` |
|
||||
| `08-test-compile-errors.txt` | `@MockBean` and the old `@AutoConfigureMockMvc` package |
|
||||
| `09-test-runtime-failure.txt` | a plain `@Mock` field is `null`: compiles, fails at run time |
|
||||
| `10-security-dsl-compile-errors.txt` | the real javac message for the removed `authorizeHttpRequests()` chain |
|
||||
|
||||
Not covered: Gradle, Kotlin, native image, and any library that has not shipped a Boot 4 release.
|
||||
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- THE "AFTER" APP: the same service migrated to Spring Boot 4.1.2. Builds on its own:
|
||||
mvn -B verify
|
||||
The "before" sources live in ../before and are never part of this build. -->
|
||||
<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.2</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
<groupId>com.ankurm</groupId>
|
||||
<artifactId>migration-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<!-- main code: one starter per technology -->
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-flyway</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aspectj</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
|
||||
<!-- test code: each starter has a -test companion -->
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webmvc-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security-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,11 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DemoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Takes the auto-configured RestClient.Builder. RestClient itself lives in spring-web, so this always compiles. */
|
||||
@Component
|
||||
public class Gateway {
|
||||
private final RestClient client;
|
||||
|
||||
public Gateway(RestClient.Builder builder) {
|
||||
this.client = builder.baseUrl("http://localhost:9").build();
|
||||
}
|
||||
|
||||
public String describe() {
|
||||
return client.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Greeting(long id, String title, Instant createdAt, String author) {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class GreetingController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GreetingController.class);
|
||||
|
||||
private final GreetingService service;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final Gateway gateway;
|
||||
|
||||
public GreetingController(GreetingService service, JdbcTemplate jdbc, Gateway gateway) {
|
||||
this.service = service;
|
||||
this.jdbc = jdbc;
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
@GetMapping("/greetings/{id}")
|
||||
public Greeting greeting(@PathVariable long id) {
|
||||
log.info("served greeting {} ........................................", id);
|
||||
return new Greeting(id, service.title(id), Instant.parse("2026-01-02T03:04:05Z"), "ankur");
|
||||
}
|
||||
|
||||
/** Serialised by Jackson's default record handling (no custom serializer). */
|
||||
@GetMapping("/summary")
|
||||
public Summary summary() {
|
||||
Summary s = new Summary();
|
||||
s.setId(1);
|
||||
s.setTitle("Hello");
|
||||
s.setCreatedAt(Instant.parse("2026-01-02T03:04:05Z"));
|
||||
s.setAuthor("ankur");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Reads the table that the Flyway migration V1 creates. */
|
||||
@GetMapping("/greetings/count")
|
||||
public Map<String, Object> count() {
|
||||
Integer n = jdbc.queryForObject("select count(*) from greeting", Integer.class);
|
||||
return Map.of("rows", n);
|
||||
}
|
||||
|
||||
/** How many times the @Aspect has intercepted GreetingService. */
|
||||
@GetMapping("/aspect")
|
||||
public Map<String, Object> aspect() {
|
||||
return Map.of("interceptions", TimingAspect.COUNT.get());
|
||||
}
|
||||
|
||||
@GetMapping("/gateway")
|
||||
public Map<String, Object> gateway() {
|
||||
return Map.of("client", gateway.describe());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class GreetingService {
|
||||
public String title(long id) {
|
||||
return "Hello #" + id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.boot.jackson.JacksonComponent;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.core.JsonGenerator;
|
||||
import tools.jackson.databind.SerializationContext;
|
||||
import tools.jackson.databind.ser.std.StdSerializer;
|
||||
|
||||
/** Boot 4: Jackson 3 (tools.jackson), @JacksonComponent, and no checked IOException. */
|
||||
@JacksonComponent
|
||||
public class JsonConfig extends StdSerializer<Greeting> {
|
||||
|
||||
public JsonConfig() {
|
||||
super(Greeting.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void serialize(Greeting g, JsonGenerator gen, SerializationContext ctxt) throws JacksonException {
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberProperty("id", g.id());
|
||||
gen.writeStringProperty("title", g.title());
|
||||
gen.writeStringProperty("createdAt", g.createdAt().toString());
|
||||
gen.writeStringProperty("author", g.author());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.Customizer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(SecurityFilterProperties.BASIC_AUTH_ORDER)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
http.authorizeHttpRequests(a -> a
|
||||
.requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
|
||||
.anyRequest().authenticated())
|
||||
.httpBasic(Customizer.withDefaults());
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A plain bean (not a record) whose properties are deliberately NOT in alphabetical order.
|
||||
* Jackson 2 wrote them in declaration order; Jackson 3 sorts bean properties alphabetically by default.
|
||||
* (Records are unaffected: creator parameters are always written first, in constructor order.)
|
||||
*/
|
||||
public class Summary {
|
||||
private long id;
|
||||
private String title;
|
||||
private Instant createdAt;
|
||||
private String author;
|
||||
|
||||
public long getId() { return id; }
|
||||
public void setId(long id) { this.id = id; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
public String getAuthor() { return author; }
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Needs AspectJ weaving support on the classpath: spring-boot-starter-aop in Boot 3. */
|
||||
@Aspect
|
||||
@Component
|
||||
public class TimingAspect {
|
||||
static final AtomicInteger COUNT = new AtomicInteger();
|
||||
|
||||
@Around("execution(* com.ankurm.migration.GreetingService.*(..))")
|
||||
public Object count(ProceedingJoinPoint pjp) throws Throwable {
|
||||
COUNT.incrementAndGet();
|
||||
return pjp.proceed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
|
||||
management.endpoints.web.exposure.include=health,metrics
|
||||
management.endpoint.health.show-details=always
|
||||
logging.file.name=${LOG_DIR:target/logs}/app.log
|
||||
# Boot 4.1 removed the old logging.file.* rolling keys; these are the replacements
|
||||
logging.logback.rollingpolicy.max-file-size=2KB
|
||||
logging.logback.rollingpolicy.max-history=50
|
||||
@@ -0,0 +1,2 @@
|
||||
create table greeting (id bigint primary key, title varchar(100) not null);
|
||||
insert into greeting values (1, 'first'), (2, 'second'), (3, 'third');
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/** Prints the status of a Basic-authenticated POST without a CSRF token (to compare 3.5 with 4.1). */
|
||||
@SpringBootTest(properties = "spring.security.user.password=pw")
|
||||
@AutoConfigureMockMvc
|
||||
class CsrfProbeTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void postWithoutCsrfToken() throws Exception {
|
||||
int status = mvc.perform(post("/anything").with(httpBasic("user", "pw"))).andReturn().getResponse().getStatus();
|
||||
System.out.println("CSRFPROBE status=" + status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
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.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class GreetingControllerTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@MockitoBean GreetingService service;
|
||||
|
||||
@Test
|
||||
void greetingUsesService() throws Exception {
|
||||
given(service.title(7)).willReturn("mocked");
|
||||
mvc.perform(get("/greetings/7")).andExpect(status().isOk()).andExpect(jsonPath("$.title").value("mocked"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/** Boot 4 fix: Mockito's own extension initialises @Mock fields. */
|
||||
@SpringBootTest
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class MockFieldTest {
|
||||
|
||||
@Mock Runnable task;
|
||||
|
||||
@Test
|
||||
void mockFieldIsInitialised() {
|
||||
assertNotNull(task, "@Mock field was never initialised");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
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.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/** Pins the runtime behaviour the "breaks at runtime" checklist is about, so a regression fails the build. */
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class RuntimeFactsTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@Autowired JdbcTemplate jdbc;
|
||||
|
||||
@Test
|
||||
void flywayMigrationRan() {
|
||||
assertThat(jdbc.queryForObject("select count(*) from greeting", Integer.class)).isEqualTo(3);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aspectIntercepts() throws Exception {
|
||||
mvc.perform(get("/greetings/1")).andExpect(status().isOk());
|
||||
assertThat(TimingAspect.COUNT.get()).isPositive();
|
||||
}
|
||||
|
||||
@Test
|
||||
void restClientBuilderIsAutoConfigured() throws Exception {
|
||||
mvc.perform(get("/gateway")).andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void metricsEndpointExists() throws Exception {
|
||||
mvc.perform(get("/actuator/metrics").with(user("u")))
|
||||
.andExpect(status().isOk());
|
||||
}
|
||||
|
||||
@Test
|
||||
void serializerIsTheJacksonComponent() throws Exception {
|
||||
mvc.perform(get("/greetings/2")).andExpect(content().json("{\"id\":2,\"title\":\"Hello #2\"}"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- THE "BEFORE" APP: a Spring Boot 3.5.16 service written the way most 3.x services are.
|
||||
It is NOT part of any build in this repository; scripts/run-all.sh copies it to a scratch
|
||||
directory and builds it there, first as-is and then with the parent bumped to 4.1.2. -->
|
||||
<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>migration-demo</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<properties>
|
||||
<java.version>25</java.version>
|
||||
</properties>
|
||||
<dependencies>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
|
||||
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>runtime</scope></dependency>
|
||||
<!-- Boot 3: Flyway needs only the library, Boot's auto-configuration was in the big autoconfigure jar -->
|
||||
<dependency><groupId>org.flywaydb</groupId><artifactId>flyway-core</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>org.springframework.security</groupId><artifactId>spring-security-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,11 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class DemoApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(DemoApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
/** Takes the auto-configured RestClient.Builder. RestClient itself lives in spring-web, so this always compiles. */
|
||||
@Component
|
||||
public class Gateway {
|
||||
private final RestClient client;
|
||||
|
||||
public Gateway(RestClient.Builder builder) {
|
||||
this.client = builder.baseUrl("http://localhost:9").build();
|
||||
}
|
||||
|
||||
public String describe() {
|
||||
return client.getClass().getSimpleName();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record Greeting(long id, String title, Instant createdAt, String author) {
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
public class GreetingController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(GreetingController.class);
|
||||
|
||||
private final GreetingService service;
|
||||
private final JdbcTemplate jdbc;
|
||||
private final Gateway gateway;
|
||||
|
||||
public GreetingController(GreetingService service, JdbcTemplate jdbc, Gateway gateway) {
|
||||
this.service = service;
|
||||
this.jdbc = jdbc;
|
||||
this.gateway = gateway;
|
||||
}
|
||||
|
||||
@GetMapping("/greetings/{id}")
|
||||
public Greeting greeting(@PathVariable long id) {
|
||||
log.info("served greeting {} ........................................", id);
|
||||
return new Greeting(id, service.title(id), Instant.parse("2026-01-02T03:04:05Z"), "ankur");
|
||||
}
|
||||
|
||||
/** Serialised by Jackson's default record handling (no custom serializer). */
|
||||
@GetMapping("/summary")
|
||||
public Summary summary() {
|
||||
Summary s = new Summary();
|
||||
s.setId(1);
|
||||
s.setTitle("Hello");
|
||||
s.setCreatedAt(Instant.parse("2026-01-02T03:04:05Z"));
|
||||
s.setAuthor("ankur");
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Reads the table that the Flyway migration V1 creates. */
|
||||
@GetMapping("/greetings/count")
|
||||
public Map<String, Object> count() {
|
||||
Integer n = jdbc.queryForObject("select count(*) from greeting", Integer.class);
|
||||
return Map.of("rows", n);
|
||||
}
|
||||
|
||||
/** How many times the @Aspect has intercepted GreetingService. */
|
||||
@GetMapping("/aspect")
|
||||
public Map<String, Object> aspect() {
|
||||
return Map.of("interceptions", TimingAspect.COUNT.get());
|
||||
}
|
||||
|
||||
@GetMapping("/gateway")
|
||||
public Map<String, Object> gateway() {
|
||||
return Map.of("client", gateway.describe());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class GreetingService {
|
||||
public String title(long id) {
|
||||
return "Hello #" + id;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
import org.springframework.boot.jackson.JsonComponent;
|
||||
|
||||
/** Boot 3 idiom: a Jackson 2 serializer registered through Boot's @JsonComponent. */
|
||||
@JsonComponent
|
||||
public class JsonConfig extends JsonSerializer<Greeting> {
|
||||
@Override
|
||||
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
|
||||
gen.writeStartObject();
|
||||
gen.writeNumberField("id", g.id());
|
||||
gen.writeStringField("title", g.title());
|
||||
gen.writeStringField("createdAt", g.createdAt().toString());
|
||||
gen.writeStringField("author", g.author());
|
||||
gen.writeEndObject();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import org.springframework.boot.autoconfigure.security.SecurityProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
@Order(SecurityProperties.BASIC_AUTH_ORDER)
|
||||
SecurityFilterChain chain(HttpSecurity http) throws Exception {
|
||||
http.authorizeHttpRequests()
|
||||
.requestMatchers("/actuator/health/**", "/greetings/**", "/aspect", "/gateway").permitAll()
|
||||
.anyRequest().authenticated()
|
||||
.and()
|
||||
.httpBasic();
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A plain bean (not a record) whose properties are deliberately NOT in alphabetical order.
|
||||
* Jackson 2 wrote them in declaration order; Jackson 3 sorts bean properties alphabetically by default.
|
||||
* (Records are unaffected: creator parameters are always written first, in constructor order.)
|
||||
*/
|
||||
public class Summary {
|
||||
private long id;
|
||||
private String title;
|
||||
private Instant createdAt;
|
||||
private String author;
|
||||
|
||||
public long getId() { return id; }
|
||||
public void setId(long id) { this.id = id; }
|
||||
public String getTitle() { return title; }
|
||||
public void setTitle(String title) { this.title = title; }
|
||||
public Instant getCreatedAt() { return createdAt; }
|
||||
public void setCreatedAt(Instant createdAt) { this.createdAt = createdAt; }
|
||||
public String getAuthor() { return author; }
|
||||
public void setAuthor(String author) { this.author = author; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.aspectj.lang.ProceedingJoinPoint;
|
||||
import org.aspectj.lang.annotation.Around;
|
||||
import org.aspectj.lang.annotation.Aspect;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Needs AspectJ weaving support on the classpath: spring-boot-starter-aop in Boot 3. */
|
||||
@Aspect
|
||||
@Component
|
||||
public class TimingAspect {
|
||||
static final AtomicInteger COUNT = new AtomicInteger();
|
||||
|
||||
@Around("execution(* com.ankurm.migration.GreetingService.*(..))")
|
||||
public Object count(ProceedingJoinPoint pjp) throws Throwable {
|
||||
COUNT.incrementAndGet();
|
||||
return pjp.proceed();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
spring.datasource.url=jdbc:h2:mem:demo;DB_CLOSE_DELAY=-1
|
||||
management.endpoints.web.exposure.include=health,metrics
|
||||
management.endpoint.health.show-details=always
|
||||
# Boot 3 logging properties that were deprecated for years (replaced by logging.logback.rollingpolicy.*)
|
||||
logging.file.name=${LOG_DIR:target/logs}/app.log
|
||||
logging.file.max-size=2KB
|
||||
logging.file.max-history=50
|
||||
@@ -0,0 +1,2 @@
|
||||
create table greeting (id bigint primary key, title varchar(100) not null);
|
||||
insert into greeting values (1, 'first'), (2, 'second'), (3, 'third');
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
/** Prints the status of a Basic-authenticated POST without a CSRF token (to compare 3.5 with 4.1). */
|
||||
@SpringBootTest(properties = "spring.security.user.password=pw")
|
||||
@AutoConfigureMockMvc
|
||||
class CsrfProbeTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
|
||||
@Test
|
||||
void postWithoutCsrfToken() throws Exception {
|
||||
int status = mvc.perform(post("/anything").with(httpBasic("user", "pw"))).andReturn().getResponse().getStatus();
|
||||
System.out.println("CSRFPROBE status=" + status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
|
||||
@SpringBootTest
|
||||
@AutoConfigureMockMvc
|
||||
class GreetingControllerTest {
|
||||
|
||||
@Autowired MockMvc mvc;
|
||||
@MockBean GreetingService service;
|
||||
|
||||
@Test
|
||||
void greetingUsesService() throws Exception {
|
||||
given(service.title(7)).willReturn("mocked");
|
||||
mvc.perform(get("/greetings/7")).andExpect(status().isOk()).andExpect(jsonPath("$.title").value("mocked"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.ankurm.migration;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
/**
|
||||
* A plain Mockito @Mock field inside a @SpringBootTest, with no MockitoExtension.
|
||||
* Boot 3 initialised it through MockitoTestExecutionListener; Boot 4 removed that listener.
|
||||
*/
|
||||
@SpringBootTest
|
||||
class MockFieldTest {
|
||||
|
||||
@Mock Runnable task;
|
||||
|
||||
@Test
|
||||
void mockFieldIsInitialised() {
|
||||
assertNotNull(task, "@Mock field was never initialised");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
$ mvn package # before/ as written: Boot 3.5.16, JDK 25.0.5
|
||||
CSRFPROBE status=403
|
||||
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
|
||||
$ java -jar migration-demo-1.0.0.jar # Boot 3.5.16
|
||||
GET /summary
|
||||
{"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
GET /greetings/1
|
||||
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
GET /greetings/count
|
||||
{"rows":3} -> HTTP 200
|
||||
GET /aspect
|
||||
{"interceptions":1} -> HTTP 200
|
||||
GET /gateway
|
||||
{"client":"DefaultRestClient"} -> HTTP 200
|
||||
GET /actuator/metrics (first 60 chars)
|
||||
{"names":["application.ready.time","application.started.time
|
||||
GET /actuator/health (groups and status only)
|
||||
groups=None status=UP components=['db', 'diskSpace', 'ping', 'ssl']
|
||||
GET /actuator/health/liveness
|
||||
-> HTTP 404
|
||||
after 40 more requests, log directory holds: 1 app.log; 5 app.log.<date>.<n>.gz; (app.log is 342 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
|
||||
@@ -0,0 +1,35 @@
|
||||
$ mvn -DskipTests package # same sources, same pom, only <parent><version> 3.5.16 -> 4.1.2
|
||||
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
|
||||
[ERROR] 'dependencies.dependency.version' for org.springframework.boot:spring-boot-starter-aop:jar is missing. @ line 25, column 17
|
||||
|
||||
# spring-boot-starter-aop has no managed version any more. Rename it to spring-boot-starter-aspectj and try again:
|
||||
$ mvn compile
|
||||
[ERROR] Failed to execute goal org.apache.maven.plugins:maven-compiler-plugin:3.15.0:compile (default-compile) on project migration-demo: Fatal error compiling: Cannot load from object array because "this.hashes" is null
|
||||
[INFO] BUILD FAILURE
|
||||
# Maven's compiler plugin 3.15.0 on JDK 25 crashed inside javac instead of listing the errors, so ask javac directly:
|
||||
$ javac -cp $(mvn dependency:build-classpath) src/main/java/**/*.java
|
||||
SecurityConfig.java:3: error: package org.springframework.boot.autoconfigure.security does not exist
|
||||
import org.springframework.boot.autoconfigure.security.SecurityProperties;
|
||||
JsonConfig.java:5: error: package com.fasterxml.jackson.core does not exist
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
JsonConfig.java:6: error: package com.fasterxml.jackson.databind does not exist
|
||||
import com.fasterxml.jackson.databind.JsonSerializer;
|
||||
JsonConfig.java:7: error: package com.fasterxml.jackson.databind does not exist
|
||||
import com.fasterxml.jackson.databind.SerializerProvider;
|
||||
JsonConfig.java:8: error: cannot find symbol
|
||||
import org.springframework.boot.jackson.JsonComponent;
|
||||
JsonConfig.java:12: error: cannot find symbol
|
||||
public class JsonConfig extends JsonSerializer<Greeting> {
|
||||
JsonConfig.java:11: error: cannot find symbol
|
||||
@JsonComponent
|
||||
JsonConfig.java:14: error: cannot find symbol
|
||||
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
|
||||
JsonConfig.java:14: error: cannot find symbol
|
||||
public void serialize(Greeting g, JsonGenerator gen, SerializerProvider sp) throws IOException {
|
||||
SecurityConfig.java:14: error: cannot find symbol
|
||||
@Order(SecurityProperties.BASIC_AUTH_ORDER)
|
||||
SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
|
||||
http.authorizeHttpRequests()
|
||||
JsonConfig.java:13: error: method does not override or implement a method from a supertype
|
||||
@Override
|
||||
javac error count: 12
|
||||
@@ -0,0 +1,11 @@
|
||||
$ # s2 = before/ + Boot 4.1.2 + aop->aspectj + JsonConfig and SecurityConfig ported (imports only); every other dependency untouched
|
||||
$ mvn -DskipTests package
|
||||
[INFO] BUILD SUCCESS
|
||||
|
||||
$ java -jar migration-demo-1.0.0.jar
|
||||
APPLICATION DID NOT START. The failure report:
|
||||
APPLICATION FAILED TO START
|
||||
***************************
|
||||
Description:
|
||||
Parameter 0 of constructor in com.ankurm.migration.Gateway required a bean of type 'org.springframework.web.client.RestClient$Builder' that could not be found.
|
||||
Action:
|
||||
@@ -0,0 +1,25 @@
|
||||
$ # s3 = s2 + spring-boot-starter-restclient (the only thing the start-up failure asked for). Flyway, properties and the rest still Boot 3 style.
|
||||
$ mvn -DskipTests package
|
||||
[INFO] BUILD SUCCESS
|
||||
|
||||
$ java -jar migration-demo-1.0.0.jar
|
||||
GET /summary
|
||||
{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
|
||||
GET /greetings/1
|
||||
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
GET /greetings/count
|
||||
{"timestamp":"2026-10-29T05:55:01.660Z","status":500,"error":"Internal Server Error","path":"/greetings/count"} -> HTTP 500
|
||||
GET /aspect
|
||||
{"interceptions":1} -> HTTP 200
|
||||
GET /gateway
|
||||
{"client":"DefaultRestClient"} -> HTTP 200
|
||||
GET /actuator/metrics (first 60 chars)
|
||||
{"names":["application.ready.time","application.started.time
|
||||
GET /actuator/health (groups and status only)
|
||||
groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
|
||||
GET /actuator/health/liveness
|
||||
-> HTTP 200
|
||||
root cause of that 500 in the application log:
|
||||
Table "GREETING" not found
|
||||
Flyway log lines at start-up: 0
|
||||
after 40 more requests, log directory holds: 1 app.log; (app.log is 54188 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
|
||||
@@ -0,0 +1,13 @@
|
||||
$ # s6 = s3 + spring-boot-properties-migrator (runtime scope)
|
||||
[INFO] BUILD SUCCESS
|
||||
app started
|
||||
$ grep -iE 'propert|logging.file|migrat' run.log # what the migrator printed at start-up
|
||||
WARN 2635 --- [ main] o.s.b.c.p.m.PropertiesMigrationListener :
|
||||
he use of configuration keys that have been renamed was found in the environment:
|
||||
Property source 'Config resource 'class path resource [application.properties]' via location 'optional:classpath:/'':
|
||||
Key: logging.file.max-history
|
||||
Replacement: logging.logback.rollingpolicy.max-history
|
||||
Key: logging.file.max-size
|
||||
Replacement: logging.logback.rollingpolicy.max-file-size
|
||||
Each configuration key has been temporarily mapped to its replacement for your convenience. To silence this warning, please update your configuration to use the new keys.
|
||||
(lines matching: 3)
|
||||
@@ -0,0 +1,23 @@
|
||||
$ mvn verify # after/ : Boot 4.1.2, one starter per technology, -test companions, Jackson 3, new packages
|
||||
CSRFPROBE status=403
|
||||
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD SUCCESS
|
||||
|
||||
$ java -jar migration-demo-1.0.0.jar
|
||||
GET /summary
|
||||
{"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
|
||||
GET /greetings/1
|
||||
{"id":1,"title":"Hello #1","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
GET /greetings/count
|
||||
{"rows":3} -> HTTP 200
|
||||
GET /aspect
|
||||
{"interceptions":1} -> HTTP 200
|
||||
GET /gateway
|
||||
{"client":"DefaultRestClient"} -> HTTP 200
|
||||
GET /actuator/metrics (first 60 chars)
|
||||
{"names":["application.ready.time","application.started.time
|
||||
GET /actuator/health (groups and status only)
|
||||
groups=['liveness', 'readiness'] status=UP components=['db', 'diskSpace', 'livenessState', 'ping', 'readinessState', 'ssl']
|
||||
GET /actuator/health/liveness
|
||||
-> HTTP 200
|
||||
after 40 more requests, log directory holds: 1 app.log; 5 app.log.<date>.<n>.gz; (app.log is 513 bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)
|
||||
@@ -0,0 +1,4 @@
|
||||
# GET /summary on the same bean (id, title, createdAt, author declared in that order)
|
||||
Boot 3.5.16 (Jackson 2): {"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
Boot 4.1.2 (Jackson 3, defaults): {"author":"ankur","createdAt":"2026-01-02T03:04:05Z","id":1,"title":"Hello"} -> HTTP 200
|
||||
Boot 4.1.2 + spring.jackson.use-jackson2-defaults: {"id":1,"title":"Hello","createdAt":"2026-01-02T03:04:05Z","author":"ankur"} -> HTTP 200
|
||||
@@ -0,0 +1,6 @@
|
||||
$ mvn test-compile # after/ pom and main code, but the Boot 3 GreetingControllerTest (@MockBean, old @AutoConfigureMockMvc package)
|
||||
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[10,63] package org.springframework.boot.test.autoconfigure.web.servlet does not exist
|
||||
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[12,50] package org.springframework.boot.test.mock.mockito does not exist
|
||||
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[16,2] cannot find symbol
|
||||
t1/src/test/java/com/ankurm/migration/GreetingControllerTest.java:[20,6] cannot find symbol
|
||||
[INFO] BUILD FAILURE
|
||||
@@ -0,0 +1,5 @@
|
||||
$ mvn test # after/ pom and main code + the Boot 3 MockFieldTest unchanged (compiles) and CsrfProbeTest (one import fixed)
|
||||
CSRFPROBE status=403
|
||||
[ERROR] MockFieldTest.mockFieldIsInitialised:20 @Mock field was never initialised ==> expected: not <null>
|
||||
[ERROR] Tests run: 2, Failures: 1, Errors: 0, Skipped: 0
|
||||
[INFO] BUILD FAILURE
|
||||
@@ -0,0 +1,8 @@
|
||||
$ javac SecurityConfig.java # Boot 3 file, import repointed to the 4.1.2 package; the old method-chaining DSL left as it was
|
||||
SecurityConfig.java:16: error: method authorizeHttpRequests in class HttpSecurity cannot be applied to given types;
|
||||
http.authorizeHttpRequests()
|
||||
^
|
||||
required: Customizer<AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry>
|
||||
found: no arguments
|
||||
reason: actual and formal argument lists differ in length
|
||||
1 error
|
||||
Executable
+131
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env bash
|
||||
# Regenerates every file in output/. Needs JDK 25 (JAVA_HOME), Maven 3.9 and network access to Maven Central.
|
||||
# The "before" app (Boot 3.5.16) is never built in place: each stage is copied to a scratch directory.
|
||||
# Takes 15-25 minutes on a shared 2-vCPU box (each stage is a real Maven build and most start the app).
|
||||
set -u
|
||||
HERE=$(cd "$(dirname "$0")/.." && pwd); OUT=$HERE/output; W=${WORK:-/tmp/b4m-work}
|
||||
MVN=${MVN:-mvn}; PORT=${PORT:-18090}; URL=http://localhost:$PORT
|
||||
BOOT_OLD=3.5.16; BOOT_NEW=4.1.2
|
||||
mkdir -p "$OUT"; rm -rf "$W"; mkdir -p "$W"
|
||||
|
||||
mv_() { local d=$1; shift; (cd "$d" && $MVN -B -T1 "$@" 2>&1 | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING: |Download(ing|ed)' | sed "s#$W/##g"); }
|
||||
stage() { rm -rf "$W/$1"; mkdir -p "$W/$1"; cp -r "$HERE/$2/pom.xml" "$HERE/$2/src" "$W/$1/"; }
|
||||
bump() { sed -i "s#<version>$BOOT_OLD</version>#<version>$BOOT_NEW</version>#" "$W/$1/pom.xml"; }
|
||||
AFTER_MAIN=$HERE/after/src/main/java/com/ankurm/migration
|
||||
BEFORE_TEST=$HERE/before/src/test/java/com/ankurm/migration
|
||||
|
||||
# start <label> <jar> [extra args]; leaves the app running on $PORT, pid in $W/pid
|
||||
start() {
|
||||
local label=$1 jar=$2; shift 2
|
||||
rm -rf "$W/logs-$label"
|
||||
LOG_DIR="$W/logs-$label" java -jar "$jar" --server.port=$PORT --spring.security.user.password=pw "$@" > "$W/run-$label.log" 2>&1 &
|
||||
echo $! > "$W/pid"
|
||||
for _ in $(seq 1 240); do curl -s -o /dev/null $URL/actuator/health && return 0; kill -0 "$(cat "$W/pid")" 2>/dev/null || return 1; sleep 1; done
|
||||
return 1
|
||||
}
|
||||
stop() { kill "$(cat "$W/pid")" 2>/dev/null; wait "$(cat "$W/pid")" 2>/dev/null; sleep 1; }
|
||||
g() { echo "GET $1"; curl -s -u user:pw -w ' -> HTTP %{http_code}\n' "$URL$1" | cut -c1-200; }
|
||||
# probe <label> <jar> [extra args] -> prints what the running app does
|
||||
probe() {
|
||||
local label=$1; shift
|
||||
if ! start "$label" "$@"; then
|
||||
echo "APPLICATION DID NOT START. The failure report:"; sed -n '/APPLICATION FAILED TO START/,/^Action:/p' "$W/run-$label.log" | grep -v '^$' | head -12
|
||||
stop; return
|
||||
fi
|
||||
g /summary; g /greetings/1; g /greetings/count; g /aspect; g /gateway
|
||||
echo "GET /actuator/metrics (first 60 chars)"; curl -s -u user:pw -w ' -> HTTP %{http_code}\n' $URL/actuator/metrics | cut -c1-60
|
||||
echo "GET /actuator/health (groups and status only)"
|
||||
curl -s $URL/actuator/health | python3 -c 'import sys,json; d=json.load(sys.stdin); print(" groups=%s status=%s components=%s" % (d.get("groups"), d["status"], sorted(d.get("components",{}))))'
|
||||
echo "GET /actuator/health/liveness"; curl -s -o /dev/null -w ' -> HTTP %{http_code}\n' $URL/actuator/health/liveness
|
||||
if curl -s -u user:pw $URL/greetings/count | grep -q '"status":500'; then echo "root cause of that 500 in the application log:"; grep -oE 'Table "[A-Z]+" not found' "$W/run-$label.log" | sort -u | sed 's/^/ /'; echo "Flyway log lines at start-up: $(grep -c 'org.flywaydb' "$W/run-$label.log")"; fi
|
||||
for _ in $(seq 1 40); do curl -s $URL/greetings/1 > /dev/null; done
|
||||
echo "after 40 more requests, log directory holds: $(ls "$W/logs-$label" | sed 's/\.[0-9-]*\.[0-9]*\.gz/.<date>.<n>.gz/' | sort | uniq -c | tr -s ' ' | tr '\n' ';') (app.log is $(stat -c %s "$W/logs-$label/app.log") bytes; limit set is logging.file.max-size=2KB or its 4.1 replacement)"
|
||||
stop
|
||||
}
|
||||
hdr() { echo "\$ $*"; }
|
||||
|
||||
# ---------------------------------------------------------------- 01 baseline on Boot 3.5.16
|
||||
stage s0 before
|
||||
{ hdr "mvn package # before/ as written: Boot $BOOT_OLD, JDK $(java -version 2>&1 | grep -v JAVA_TOOL | head -1 | cut -d'"' -f2)"
|
||||
mv_ "$W/s0" package > "$W/s0.log"; grep -E 'Tests run:|CSRFPROBE|BUILD' "$W/s0.log" | grep -v 'Time elapsed'
|
||||
echo; hdr "java -jar migration-demo-1.0.0.jar # Boot $BOOT_OLD"
|
||||
probe boot35 "$W/s0/target/migration-demo-1.0.0.jar"; } > "$OUT/01-before-boot35.txt"
|
||||
cp "$OUT/01-before-boot35.txt" "$W/probe-boot35.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 02 change nothing but the parent version
|
||||
stage s1 before; bump s1
|
||||
{ hdr "mvn -DskipTests package # same sources, same pom, only <parent><version> $BOOT_OLD -> $BOOT_NEW"
|
||||
mv_ "$W/s1" -DskipTests package | grep -E 'ERROR.*(dependency|missing)|BUILD' | sort -u
|
||||
echo; echo "# spring-boot-starter-aop has no managed version any more. Rename it to spring-boot-starter-aspectj and try again:"
|
||||
sed -i 's/spring-boot-starter-aop/spring-boot-starter-aspectj/' "$W/s1/pom.xml"
|
||||
hdr "mvn compile"
|
||||
mv_ "$W/s1" compile > "$W/s1.log"
|
||||
grep -E 'BUILD|Fatal error compiling' "$W/s1.log" | sed 's/ -> \[Help 1\]//' | sort -u | cut -c1-230
|
||||
echo "# Maven's compiler plugin 3.15.0 on JDK 25 crashed inside javac instead of listing the errors, so ask javac directly:"
|
||||
hdr "javac -cp \$(mvn dependency:build-classpath) src/main/java/**/*.java"
|
||||
(cd "$W/s1" && $MVN -B -q dependency:build-classpath -Dmdep.outputFile="$W/cp.txt" > /dev/null 2>&1; mkdir -p "$W/jc"
|
||||
javac -d "$W/jc" -cp "$(cat "$W/cp.txt")" $(find src/main -name '*.java') 2>&1 | grep -v JAVA_TOOL > "$W/s1.javac")
|
||||
grep -A1 -E 'error:' "$W/s1.javac" | grep -v '^--' | sed 's#^src/main/java/com/ankurm/migration/##'
|
||||
echo "javac error count: $(grep -c 'error:' "$W/s1.javac")"; } > "$OUT/02-bump-only-compile-errors.txt"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- 03 imports fixed, starters untouched -> startup failure
|
||||
stage s2 before; bump s2; sed -i 's/spring-boot-starter-aop/spring-boot-starter-aspectj/' "$W/s2/pom.xml"
|
||||
rm -rf "$W/s2/src/test"; cp "$AFTER_MAIN"/JsonConfig.java "$AFTER_MAIN"/SecurityConfig.java "$W/s2/src/main/java/com/ankurm/migration/"
|
||||
{ hdr "# s2 = before/ + Boot $BOOT_NEW + aop->aspectj + JsonConfig and SecurityConfig ported (imports only); every other dependency untouched"
|
||||
hdr "mvn -DskipTests package"
|
||||
mv_ "$W/s2" -DskipTests package | grep -E 'ERROR|BUILD'
|
||||
echo; hdr "java -jar migration-demo-1.0.0.jar"
|
||||
probe starters-missing "$W/s2/target/migration-demo-1.0.0.jar"; } > "$OUT/03-compiles-but-will-not-start.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 04 start-up fixed -> the silent ones
|
||||
stage s3 before; bump s3; sed -i 's/spring-boot-starter-aop/spring-boot-starter-aspectj/' "$W/s3/pom.xml"
|
||||
rm -rf "$W/s3/src/test"; cp "$AFTER_MAIN"/JsonConfig.java "$AFTER_MAIN"/SecurityConfig.java "$W/s3/src/main/java/com/ankurm/migration/"
|
||||
sed -i 's#<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc#<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc#' "$W/s3/pom.xml"
|
||||
{ hdr "# s3 = s2 + spring-boot-starter-restclient (the only thing the start-up failure asked for). Flyway, properties and the rest still Boot 3 style."
|
||||
hdr "mvn -DskipTests package"
|
||||
mv_ "$W/s3" -DskipTests package | grep -E 'ERROR|BUILD'
|
||||
echo; hdr "java -jar migration-demo-1.0.0.jar"
|
||||
probe silent "$W/s3/target/migration-demo-1.0.0.jar"; } > "$OUT/04-starts-but-silently-wrong.txt"
|
||||
cp "$OUT/04-starts-but-silently-wrong.txt" "$W/probe-silent.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 05 properties migrator on the silent build
|
||||
stage s6 before; bump s6; sed -i 's/spring-boot-starter-aop/spring-boot-starter-aspectj/' "$W/s6/pom.xml"
|
||||
rm -rf "$W/s6/src/test"; cp "$AFTER_MAIN"/JsonConfig.java "$AFTER_MAIN"/SecurityConfig.java "$W/s6/src/main/java/com/ankurm/migration/"
|
||||
sed -i 's#<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc#<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-restclient</artifactId></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-properties-migrator</artifactId><scope>runtime</scope></dependency>\n <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-jdbc#' "$W/s6/pom.xml"
|
||||
{ hdr "# s6 = s3 + spring-boot-properties-migrator (runtime scope)"
|
||||
mv_ "$W/s6" -DskipTests package | grep -E 'ERROR|BUILD'
|
||||
start migrator "$W/s6/target/migration-demo-1.0.0.jar" && echo "app started" || echo "app did not start"
|
||||
hdr "grep -iE 'propert|logging.file|migrat' run.log # what the migrator printed at start-up"
|
||||
grep -iE 'logging\.file|PropertiesMigration|The use of configuration keys|Property source|Reason|Replacement' "$W/run-migrator.log" | sed 's/^[0-9T:.+-]* *//' | cut -c1-220 | head -20
|
||||
echo "(lines matching: $(grep -ciE 'logging\.file|PropertiesMigration' "$W/run-migrator.log"))"
|
||||
stop; } > "$OUT/05-properties-migrator.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 06 the migrated app
|
||||
{ hdr "mvn verify # after/ : Boot $BOOT_NEW, one starter per technology, -test companions, Jackson 3, new packages"
|
||||
stage a after
|
||||
mv_ "$W/a" verify > "$W/a.log"; grep -E 'Tests run:|CSRFPROBE|BUILD' "$W/a.log" | grep -v 'Time elapsed'
|
||||
echo; hdr "java -jar migration-demo-1.0.0.jar"
|
||||
probe after "$W/a/target/migration-demo-1.0.0.jar"; } > "$OUT/06-after-boot41.txt"
|
||||
cp "$OUT/06-after-boot41.txt" "$W/probe-after.txt"
|
||||
{ hdr "java -jar migration-demo-1.0.0.jar --spring.jackson.use-jackson2-defaults=true"
|
||||
start j2 "$W/a/target/migration-demo-1.0.0.jar" --spring.jackson.use-jackson2-defaults=true && g /summary; stop; } > "$W/probe-j2.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 07 Jackson order, side by side
|
||||
{ echo "# GET /summary on the same bean (id, title, createdAt, author declared in that order)"
|
||||
echo "Boot $BOOT_OLD (Jackson 2): $(grep -A1 '^GET /summary' "$W/probe-boot35.txt" | tail -1)"
|
||||
echo "Boot $BOOT_NEW (Jackson 3, defaults): $(grep -A1 '^GET /summary' "$W/probe-after.txt" | tail -1)"
|
||||
echo "Boot $BOOT_NEW + spring.jackson.use-jackson2-defaults: $(grep -A1 '^GET /summary' "$W/probe-j2.txt" | tail -1)"; } > "$OUT/07-jackson-property-order.txt"
|
||||
|
||||
# ---------------------------------------------------------------- 08 test code
|
||||
stage t1 after; rm -rf "$W/t1/src/test"; mkdir -p "$W/t1/src/test/java/com/ankurm/migration"
|
||||
cp "$BEFORE_TEST"/GreetingControllerTest.java "$W/t1/src/test/java/com/ankurm/migration/"
|
||||
{ hdr "mvn test-compile # after/ pom and main code, but the Boot 3 GreetingControllerTest (@MockBean, old @AutoConfigureMockMvc package)"
|
||||
mv_ "$W/t1" test-compile > "$W/t1.log"; grep -E '^\[ERROR\] .*\.java' "$W/t1.log" | sed 's#^\[ERROR\] ##' | sort -u; grep -E 'BUILD' "$W/t1.log"; } > "$OUT/08-test-compile-errors.txt"
|
||||
stage t2 after; rm -rf "$W/t2/src/test"; mkdir -p "$W/t2/src/test/java/com/ankurm/migration"
|
||||
cp "$BEFORE_TEST"/MockFieldTest.java "$W/t2/src/test/java/com/ankurm/migration/"
|
||||
sed 's#org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc#org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc#' "$BEFORE_TEST"/CsrfProbeTest.java > "$W/t2/src/test/java/com/ankurm/migration/CsrfProbeTest.java"
|
||||
{ hdr "mvn test # after/ pom and main code + the Boot 3 MockFieldTest unchanged (compiles) and CsrfProbeTest (one import fixed)"
|
||||
mv_ "$W/t2" test > "$W/t2.log"; grep -E '^\[(ERROR|INFO)\] Tests run:|CSRFPROBE|^\[ERROR\] MockFieldTest|BUILD' "$W/t2.log" | grep -v 'Time elapsed'; } > "$OUT/09-test-runtime-failure.txt"
|
||||
bash "$HERE/scripts/security-dsl.sh" > /dev/null
|
||||
echo "done"; ls -l "$OUT"
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
# Output 10: the Boot 3 SecurityConfig with ONLY its import repointed (SecurityProperties -> SecurityFilterProperties),
|
||||
# compiled with javac against the Boot 4.1.2 classpath that run-all.sh already resolved ($W/cp.txt).
|
||||
set -u
|
||||
HERE=$(cd "$(dirname "$0")/.." && pwd); W=${WORK:-/tmp/b4m-work}; OUT=$HERE/output/10-security-dsl-compile-errors.txt
|
||||
D=$W/sec; rm -rf "$D"; mkdir -p "$D/classes"
|
||||
sed 's#org.springframework.boot.autoconfigure.security.SecurityProperties#org.springframework.boot.security.autoconfigure.web.servlet.SecurityFilterProperties#; s#SecurityProperties.BASIC_AUTH_ORDER#SecurityFilterProperties.BASIC_AUTH_ORDER#' \
|
||||
"$HERE/before/src/main/java/com/ankurm/migration/SecurityConfig.java" > "$D/SecurityConfig.java"
|
||||
{ echo '$ javac SecurityConfig.java # Boot 3 file, import repointed to the 4.1.2 package; the old method-chaining DSL left as it was'
|
||||
javac -d "$D/classes" -cp "$(cat "$W/cp.txt")" "$D/SecurityConfig.java" 2>&1 | grep -v JAVA_TOOL | sed "s#$D/##"; } > "$OUT"
|
||||
cat "$OUT"
|
||||
Reference in New Issue
Block a user