From 826d633841e5d4bad2a4cd1cfd2444e44686d6d9 Mon Sep 17 00:00:00 2001 From: Harshit Pathak Date: Wed, 1 Jul 2026 10:17:46 +0000 Subject: [PATCH 1/2] feat: add mysql-crud sample for Enterprise self-hosted CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A minimal Spring Boot + JDBC CRUD app (users/orders, health, stats) backed by MySQL 8, used by keploy/enterprise's self-hosted cloud-replay E2E pipeline to exercise JDBC-manifest secret obfuscation and object-storage mock upload/download against a real database — not a mock or in-memory backend. See keploy/enterprise#2200. --- README.md | 1 + mysql-crud/.gitignore | 2 + mysql-crud/Dockerfile | 14 +++ mysql-crud/README.md | 52 +++++++++ mysql-crud/pom.xml | 48 ++++++++ .../java/com/keploy/sample/ApiController.java | 106 ++++++++++++++++++ .../java/com/keploy/sample/Application.java | 11 ++ .../src/main/resources/application.properties | 14 +++ mysql-crud/src/main/resources/data.sql | 9 ++ mysql-crud/src/main/resources/schema.sql | 21 ++++ 10 files changed, 278 insertions(+) create mode 100644 mysql-crud/.gitignore create mode 100644 mysql-crud/Dockerfile create mode 100644 mysql-crud/README.md create mode 100644 mysql-crud/pom.xml create mode 100644 mysql-crud/src/main/java/com/keploy/sample/ApiController.java create mode 100644 mysql-crud/src/main/java/com/keploy/sample/Application.java create mode 100644 mysql-crud/src/main/resources/application.properties create mode 100644 mysql-crud/src/main/resources/data.sql create mode 100644 mysql-crud/src/main/resources/schema.sql diff --git a/README.md b/README.md index 4750fe16..5fd1a54e 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ This repo contains the sample for [Keploy's](https://keploy.io) Java Application 7. [Java Dynamic Deduplication](https://github.com/keploy/samples-java/tree/main/java-dedup) - A Spring Boot sample used by CI to validate Enterprise Java dynamic dedup in native, Docker, and restricted Docker replay runs. CI uses checked-in fixtures and does not record this sample in the pipeline. 8. [Dropwizard Dynamic Deduplication](https://github.com/keploy/samples-java/tree/main/dropwizard-dedup) - A Dropwizard/Jersey sample used by Enterprise CI to validate that Java dynamic dedup works outside Spring Boot with the runtime Java agent, checked-in HTTP fixtures, native launch, classpath launch, Docker, distroless, and restricted Docker. 9. [Simple Java Dynamic Deduplication](https://github.com/keploy/samples-java/tree/main/simple-java-dedup) - A minimal plain-Java HTTP server used to smoke-test Java dynamic dedup on Java 8 and Java 17 in native and Docker launch modes. +10. [MySQL CRUD](https://github.com/keploy/samples-java/tree/main/mysql-crud) - A minimal Spring Boot + JDBC CRUD app used by Enterprise CI to validate the self-hosted cloud-replay pipeline's JDBC secret-obfuscation and object-storage mock upload/download paths against a real MySQL 8 backend. ## Community Support ❤️ diff --git a/mysql-crud/.gitignore b/mysql-crud/.gitignore new file mode 100644 index 00000000..e1ffec22 --- /dev/null +++ b/mysql-crud/.gitignore @@ -0,0 +1,2 @@ +/target/ +/*.log diff --git a/mysql-crud/Dockerfile b/mysql-crud/Dockerfile new file mode 100644 index 00000000..95fe2e43 --- /dev/null +++ b/mysql-crud/Dockerfile @@ -0,0 +1,14 @@ +# Multi-stage build. NOTE: deliberately NO -javaagent — the Keploy enterprise +# sidecar auto-injects the JSSE javaagent at record/replay time. +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /src +COPY pom.xml . +RUN mvn -q -B dependency:go-offline +COPY src ./src +RUN mvn -q -B package -DskipTests + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /src/target/app.jar /app/app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/mysql-crud/README.md b/mysql-crud/README.md new file mode 100644 index 00000000..229fe2b6 --- /dev/null +++ b/mysql-crud/README.md @@ -0,0 +1,52 @@ +# MySQL CRUD Sample + +A minimal Spring Boot + JDBC application used by Keploy Enterprise CI to validate the +self-hosted cloud-replay pipeline: JDBC-manifest secret obfuscation and object-storage +mock upload/download, exercised end-to-end via a real MySQL 8 backend. + +## Endpoints + +| Method | Path | Description | +|--------|----------------------|------------------------------------------------| +| GET | `/health` | Runs `SELECT 1` against the configured DB | +| GET | `/users` | Lists users + aggregate order stats | +| GET | `/users/{id}` | Single user, their orders, and order totals | +| POST | `/users` | Creates a user | +| POST | `/users/{id}/orders` | Creates an order for a user | +| GET | `/stats` | Aggregate user/order counts and amounts | + +## Configuration + +The datasource is fully env-driven so the same jar runs against any MySQL instance: + +``` +DB_URL (default: jdbc:mysql://localhost:3306/appdb) +DB_USER (default: root) +DB_PASS (default: empty) +``` + +`schema.sql` / `data.sql` run on every startup (idempotent — `IF NOT EXISTS` / `INSERT IGNORE`). + +## MySQL auth-plugin note + +MySQL 8's default `caching_sha2_password` auth plugin cannot be captured by Keploy's +MySQL recorder mid-handshake. When running this sample against a container you control, +start MySQL with: + +``` +--default-authentication-plugin=mysql_native_password +``` + +## Build & run + +```bash +mvn -q -B clean package -DskipTests +DB_URL="jdbc:mysql://localhost:3306/appdb" DB_USER=root java -jar target/app.jar +``` + +## Docker + +```bash +docker build -t mysql-crud . +docker run -p 8080:8080 -e DB_URL="jdbc:mysql://:3306/appdb" mysql-crud +``` diff --git a/mysql-crud/pom.xml b/mysql-crud/pom.xml new file mode 100644 index 00000000..24806e35 --- /dev/null +++ b/mysql-crud/pom.xml @@ -0,0 +1,48 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.5 + + + + com.keploy.sample + mysql-crud + 0.0.1 + jar + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-jdbc + + + com.mysql + mysql-connector-j + runtime + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/mysql-crud/src/main/java/com/keploy/sample/ApiController.java b/mysql-crud/src/main/java/com/keploy/sample/ApiController.java new file mode 100644 index 00000000..4d95bc04 --- /dev/null +++ b/mysql-crud/src/main/java/com/keploy/sample/ApiController.java @@ -0,0 +1,106 @@ +package com.keploy.sample; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.bind.annotation.*; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@RestController +public class ApiController { + + private final JdbcTemplate jdbc; + + public ApiController(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + @GetMapping("/health") + public Map health() { + Integer one = jdbc.queryForObject("SELECT 1", Integer.class); + Map r = new LinkedHashMap<>(); + r.put("status", (one != null && one == 1) ? "ok" : "degraded"); + return r; + } + + @GetMapping("/users") + public Map listUsers() { + List> users = jdbc.queryForList("SELECT id,name,email FROM users ORDER BY id"); + Integer userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class); + Integer orderCount = jdbc.queryForObject("SELECT COUNT(*) FROM orders", Integer.class); + BigDecimal total = jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM orders", BigDecimal.class); + Map r = new LinkedHashMap<>(); + r.put("users", users); + r.put("userCount", userCount); + r.put("orderCount", orderCount); + r.put("totalOrderAmount", total); + return r; + } + + @GetMapping("/users/{id}") + public Map getUser(@PathVariable long id) { + List> u = jdbc.queryForList("SELECT id,name,email FROM users WHERE id=?", id); + List> orders = jdbc.queryForList( + "SELECT id,amount,status FROM orders WHERE user_id=? ORDER BY id", id); + Integer cnt = jdbc.queryForObject("SELECT COUNT(*) FROM orders WHERE user_id=?", Integer.class, id); + BigDecimal sum = jdbc.queryForObject( + "SELECT COALESCE(SUM(amount),0) FROM orders WHERE user_id=?", BigDecimal.class, id); + jdbc.update("INSERT INTO audit_log(action,detail) VALUES(?,?)", "view_user", "id=" + id); + Map r = new LinkedHashMap<>(); + r.put("user", u.isEmpty() ? null : u.get(0)); + r.put("orders", orders); + r.put("orderCount", cnt); + r.put("orderTotal", sum); + return r; + } + + @PostMapping("/users") + public Map createUser(@RequestBody Map body) { + String name = String.valueOf(body.getOrDefault("name", "unknown")); + String email = String.valueOf(body.getOrDefault("email", "unknown@example.com")); + jdbc.update("INSERT INTO users(name,email) VALUES(?,?)", name, email); + Long id = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + jdbc.update("INSERT INTO audit_log(action,detail) VALUES(?,?)", "create_user", "name=" + name); + Integer userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class); + Map created = jdbc.queryForMap("SELECT id,name,email FROM users WHERE id=?", id); + Map r = new LinkedHashMap<>(); + r.put("created", created); + r.put("userCount", userCount); + return r; + } + + @PostMapping("/users/{id}/orders") + public Map createOrder(@PathVariable long id, @RequestBody Map body) { + Object amount = body.getOrDefault("amount", 0); + String status = String.valueOf(body.getOrDefault("status", "PENDING")); + Integer userExists = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id=?", Integer.class, id); + jdbc.update("INSERT INTO orders(user_id,amount,status) VALUES(?,?,?)", id, amount, status); + Long orderId = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + Map order = jdbc.queryForMap("SELECT id,user_id,amount,status FROM orders WHERE id=?", orderId); + Integer orderCount = jdbc.queryForObject("SELECT COUNT(*) FROM orders WHERE user_id=?", Integer.class, id); + jdbc.update("INSERT INTO audit_log(action,detail) VALUES(?,?)", "create_order", "user=" + id); + Map r = new LinkedHashMap<>(); + r.put("userExists", userExists != null && userExists > 0); + r.put("order", order); + r.put("orderCountForUser", orderCount); + return r; + } + + @GetMapping("/stats") + public Map stats() { + Integer users = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class); + Integer orders = jdbc.queryForObject("SELECT COUNT(*) FROM orders", Integer.class); + BigDecimal sum = jdbc.queryForObject("SELECT COALESCE(SUM(amount),0) FROM orders", BigDecimal.class); + BigDecimal avg = jdbc.queryForObject("SELECT COALESCE(AVG(amount),0) FROM orders", BigDecimal.class); + BigDecimal max = jdbc.queryForObject("SELECT COALESCE(MAX(amount),0) FROM orders", BigDecimal.class); + Map r = new LinkedHashMap<>(); + r.put("userCount", users); + r.put("orderCount", orders); + r.put("sumAmount", sum); + r.put("avgAmount", avg); + r.put("maxAmount", max); + return r; + } +} diff --git a/mysql-crud/src/main/java/com/keploy/sample/Application.java b/mysql-crud/src/main/java/com/keploy/sample/Application.java new file mode 100644 index 00000000..9ea8eba5 --- /dev/null +++ b/mysql-crud/src/main/java/com/keploy/sample/Application.java @@ -0,0 +1,11 @@ +package com.keploy.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Application { + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } +} diff --git a/mysql-crud/src/main/resources/application.properties b/mysql-crud/src/main/resources/application.properties new file mode 100644 index 00000000..db4fce67 --- /dev/null +++ b/mysql-crud/src/main/resources/application.properties @@ -0,0 +1,14 @@ +server.port=8080 + +spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/appdb} +spring.datasource.username=${DB_USER:root} +spring.datasource.password=${DB_PASS:} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# Run schema.sql + data.sql on startup (idempotent: IF NOT EXISTS / INSERT IGNORE) +spring.sql.init.mode=always +spring.sql.init.continue-on-error=false + +# Give the pool time while wait-for-db init container / MySQL warms up +spring.datasource.hikari.initialization-fail-timeout=60000 +spring.datasource.hikari.connection-timeout=30000 diff --git a/mysql-crud/src/main/resources/data.sql b/mysql-crud/src/main/resources/data.sql new file mode 100644 index 00000000..a5d1d2f2 --- /dev/null +++ b/mysql-crud/src/main/resources/data.sql @@ -0,0 +1,9 @@ +INSERT IGNORE INTO users (id, name, email) VALUES + (1, 'Alice', 'alice@example.com'), + (2, 'Bob', 'bob@example.com'), + (3, 'Carol', 'carol@example.com'); + +INSERT IGNORE INTO orders (id, user_id, amount, status) VALUES + (1, 1, 99.50, 'PAID'), + (2, 1, 15.00, 'PENDING'), + (3, 2, 250.00, 'PAID'); diff --git a/mysql-crud/src/main/resources/schema.sql b/mysql-crud/src/main/resources/schema.sql new file mode 100644 index 00000000..7dfc682d --- /dev/null +++ b/mysql-crud/src/main/resources/schema.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS users ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS orders ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + amount DECIMAL(10,2) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + action VARCHAR(100) NOT NULL, + detail VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); From c8d7b7faeb30df217f68835396e2ca96073df0f6 Mon Sep 17 00:00:00 2001 From: Harshit Pathak Date: Wed, 1 Jul 2026 10:29:48 +0000 Subject: [PATCH 2/2] fix(mysql-crud): use generated keys instead of LAST_INSERT_ID(), reject orders for missing users LAST_INSERT_ID() is connection-scoped; a pooled JdbcTemplate follow-up call can land on a different physical connection than the INSERT and return the wrong id (or null). Use KeyHolder to read the generated key from the same statement/connection as the insert. createOrder also inserted into orders even when the target user did not exist, creating orphaned rows (no FK constraint) and a misleading 200 response. Now returns 404 before inserting. Verified locally end-to-end against a real mysql:8.0 container: createUser returns the correct row for its generated id, createOrder returns the correct order for its generated id, and a 999 (nonexistent user) orders request 404s with zero rows written. Co-Authored-By: Claude Sonnet 5 --- .../java/com/keploy/sample/ApiController.java | 46 ++++++++++++++++--- 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/mysql-crud/src/main/java/com/keploy/sample/ApiController.java b/mysql-crud/src/main/java/com/keploy/sample/ApiController.java index 4d95bc04..4c56999b 100644 --- a/mysql-crud/src/main/java/com/keploy/sample/ApiController.java +++ b/mysql-crud/src/main/java/com/keploy/sample/ApiController.java @@ -1,9 +1,15 @@ package com.keploy.sample; +import org.springframework.http.HttpStatus; import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; +import java.sql.PreparedStatement; +import java.sql.Statement; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -60,8 +66,20 @@ public Map getUser(@PathVariable long id) { public Map createUser(@RequestBody Map body) { String name = String.valueOf(body.getOrDefault("name", "unknown")); String email = String.valueOf(body.getOrDefault("email", "unknown@example.com")); - jdbc.update("INSERT INTO users(name,email) VALUES(?,?)", name, email); - Long id = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + + // LAST_INSERT_ID() is connection-scoped; a pooled JdbcTemplate call can + // land on a different physical connection than the INSERT. Use + // generated keys from the same statement/connection instead. + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO users(name,email) VALUES(?,?)", Statement.RETURN_GENERATED_KEYS); + ps.setString(1, name); + ps.setString(2, email); + return ps; + }, keyHolder); + long id = keyHolder.getKey().longValue(); + jdbc.update("INSERT INTO audit_log(action,detail) VALUES(?,?)", "create_user", "name=" + name); Integer userCount = jdbc.queryForObject("SELECT COUNT(*) FROM users", Integer.class); Map created = jdbc.queryForMap("SELECT id,name,email FROM users WHERE id=?", id); @@ -73,16 +91,30 @@ public Map createUser(@RequestBody Map body) { @PostMapping("/users/{id}/orders") public Map createOrder(@PathVariable long id, @RequestBody Map body) { - Object amount = body.getOrDefault("amount", 0); - String status = String.valueOf(body.getOrDefault("status", "PENDING")); Integer userExists = jdbc.queryForObject("SELECT COUNT(*) FROM users WHERE id=?", Integer.class, id); - jdbc.update("INSERT INTO orders(user_id,amount,status) VALUES(?,?,?)", id, amount, status); - Long orderId = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + if (userExists == null || userExists == 0) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "user " + id + " does not exist"); + } + + BigDecimal amount = new BigDecimal(String.valueOf(body.getOrDefault("amount", 0))); + String status = String.valueOf(body.getOrDefault("status", "PENDING")); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbc.update(connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO orders(user_id,amount,status) VALUES(?,?,?)", Statement.RETURN_GENERATED_KEYS); + ps.setLong(1, id); + ps.setBigDecimal(2, amount); + ps.setString(3, status); + return ps; + }, keyHolder); + long orderId = keyHolder.getKey().longValue(); + Map order = jdbc.queryForMap("SELECT id,user_id,amount,status FROM orders WHERE id=?", orderId); Integer orderCount = jdbc.queryForObject("SELECT COUNT(*) FROM orders WHERE user_id=?", Integer.class, id); jdbc.update("INSERT INTO audit_log(action,detail) VALUES(?,?)", "create_order", "user=" + id); Map r = new LinkedHashMap<>(); - r.put("userExists", userExists != null && userExists > 0); + r.put("userExists", true); r.put("order", order); r.put("orderCountForUser", orderCount); return r;