Add crud-mysql module: Spring Boot 4.1 CRUD API on a real MySQL 8.4.6 with JPA, Flyway, validation and ProblemDetail errors
Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 Co-Authored-By: Claude Sonnet 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package com.ankurm.crud;
|
||||
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import org.hibernate.engine.spi.SessionFactoryImplementor;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.boot.builder.SpringApplicationBuilder;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.sql.Connection;
|
||||
import java.sql.DatabaseMetaData;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Runs the real application against a real MySQL server (localhost:3306 unless DB_URL says otherwise)
|
||||
* and writes one transcript per scenario under output/.
|
||||
*/
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
|
||||
class CrudMysqlTests {
|
||||
|
||||
ConfigurableApplicationContext ctx;
|
||||
JdbcTemplate jdbc;
|
||||
String base;
|
||||
final HttpClient http = HttpClient.newHttpClient();
|
||||
|
||||
record Resp(int status, String location, String body) {}
|
||||
|
||||
@BeforeAll
|
||||
void start() {
|
||||
ctx = new SpringApplicationBuilder(CrudApplication.class)
|
||||
.properties("server.port=0", "spring.main.banner-mode=off", "logging.level.root=WARN",
|
||||
"spring.jpa.properties.hibernate.session_factory.statement_inspector=com.ankurm.crud.SqlRecorder")
|
||||
.run();
|
||||
jdbc = ctx.getBean(JdbcTemplate.class);
|
||||
base = "http://localhost:" + ctx.getEnvironment().getProperty("local.server.port");
|
||||
jdbc.execute("DELETE FROM book");
|
||||
jdbc.execute("ALTER TABLE book AUTO_INCREMENT = 1");
|
||||
}
|
||||
|
||||
@AfterAll
|
||||
void stop() {
|
||||
ctx.close();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
Resp call(String method, String path, String json) throws Exception {
|
||||
HttpRequest.Builder rb = HttpRequest.newBuilder(URI.create(base + path));
|
||||
if (json == null) rb.method(method, HttpRequest.BodyPublishers.noBody());
|
||||
else rb.header("Content-Type", "application/json").method(method, HttpRequest.BodyPublishers.ofString(json));
|
||||
HttpResponse<String> r = http.send(rb.build(), HttpResponse.BodyHandlers.ofString());
|
||||
String loc = r.headers().firstValue("Location").map(l -> l.replace(base, "http://localhost:8080")).orElse(null);
|
||||
return new Resp(r.statusCode(), loc, r.body());
|
||||
}
|
||||
|
||||
/** Runs a call and prints it the way you would type it with curl. */
|
||||
Resp show(Transcript t, String method, String path, String json) throws Exception {
|
||||
Resp r = call(method, path, json);
|
||||
String cmd = "$ curl -i -X " + method + " 'localhost:8080" + path + "'"
|
||||
+ (json == null ? "" : " -H 'Content-Type: application/json' -d '" + json + "'");
|
||||
t.line(cmd);
|
||||
t.line("HTTP %d%s", r.status(), r.location() == null ? "" : " Location: " + r.location());
|
||||
if (!r.body().isEmpty()) t.line(r.body());
|
||||
t.blank();
|
||||
return r;
|
||||
}
|
||||
|
||||
static final String EFFECTIVE_JAVA =
|
||||
"{\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"isbn\":\"9780134685991\",\"price\":45.50}";
|
||||
static final String JCIP =
|
||||
"{\"title\":\"Java Concurrency in Practice\",\"author\":\"Brian Goetz\",\"isbn\":\"9780321349606\",\"price\":39.99}";
|
||||
static final String CLEAN =
|
||||
"{\"title\":\"Clean Code\",\"author\":\"Robert Martin\",\"isbn\":\"9780132350884\",\"price\":33.00}";
|
||||
|
||||
// ------------------------------------------------------------------ 01
|
||||
@Test @Order(1)
|
||||
void mysqlAndSchema() throws Exception {
|
||||
try (Transcript t = new Transcript("01-mysql-and-schema.txt", "What the application is connected to and what Flyway built")) {
|
||||
try (Connection c = ctx.getBean(DataSource.class).getConnection()) {
|
||||
DatabaseMetaData m = c.getMetaData();
|
||||
t.section("the server and the driver");
|
||||
t.line("database : %s %s", m.getDatabaseProductName(), m.getDatabaseProductVersion());
|
||||
t.line("driver : %s %s", m.getDriverName(), m.getDriverVersion());
|
||||
assertThat(m.getDatabaseProductName()).isEqualTo("MySQL");
|
||||
}
|
||||
t.line("dialect : %s", ctx.getBean(EntityManagerFactory.class).unwrap(SessionFactoryImplementor.class)
|
||||
.getJdbcServices().getDialect().getClass().getSimpleName());
|
||||
t.line("ddl-auto : %s", ctx.getEnvironment().getProperty("spring.jpa.hibernate.ddl-auto"));
|
||||
|
||||
t.section("flyway_schema_history");
|
||||
List<Map<String, Object>> hist = jdbc.queryForList(
|
||||
"SELECT version, description, success FROM flyway_schema_history ORDER BY installed_rank");
|
||||
hist.forEach(h -> t.line("version=%s description=%s success=%s", h.get("version"), h.get("description"), h.get("success")));
|
||||
assertThat(hist).hasSize(1);
|
||||
|
||||
t.section("SHOW CREATE TABLE book");
|
||||
String ddl = jdbc.queryForList("SHOW CREATE TABLE book").get(0).get("Create Table").toString();
|
||||
t.line(ddl);
|
||||
assertThat(ddl).contains("uk_book_isbn").contains("AUTO_INCREMENT").contains("utf8mb4");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 02
|
||||
@Test @Order(2)
|
||||
void crudWalkthrough() throws Exception {
|
||||
try (Transcript t = new Transcript("02-crud-walkthrough.txt", "Create, read, list, update, delete against MySQL. Each block is the curl command, then the status line, the Location header if any, and the body. The test uses a random port; it is printed as 8080.")) {
|
||||
t.section("create three books");
|
||||
Resp r1 = show(t, "POST", "/api/books", EFFECTIVE_JAVA);
|
||||
Resp r2 = show(t, "POST", "/api/books", JCIP);
|
||||
Resp r3 = show(t, "POST", "/api/books", CLEAN);
|
||||
assertThat(r1.status()).isEqualTo(201);
|
||||
assertThat(r1.location()).isEqualTo("http://localhost:8080/api/books/1");
|
||||
assertThat(r3.location()).isEqualTo("http://localhost:8080/api/books/3");
|
||||
|
||||
t.section("read one");
|
||||
Resp g = show(t, "GET", "/api/books/1", null);
|
||||
assertThat(g.status()).isEqualTo(200);
|
||||
assertThat(g.body()).contains("\"title\":\"Effective Java\"");
|
||||
|
||||
t.section("list, two per page");
|
||||
Resp p0 = show(t, "GET", "/api/books?page=0&size=2", null);
|
||||
Resp p1 = show(t, "GET", "/api/books?page=1&size=2", null);
|
||||
assertThat(p0.body()).contains("\"totalElements\":3").contains("\"totalPages\":2");
|
||||
assertThat(p1.body()).contains("Clean Code");
|
||||
|
||||
t.section("filter by author");
|
||||
Resp f = show(t, "GET", "/api/books?author=goetz", null);
|
||||
assertThat(f.body()).contains("\"totalElements\":1");
|
||||
|
||||
t.section("update the price");
|
||||
Resp u = show(t, "PUT", "/api/books/1",
|
||||
"{\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"isbn\":\"9780134685991\",\"price\":42.00}");
|
||||
assertThat(u.status()).isEqualTo(200);
|
||||
assertThat(u.body()).contains("\"price\":42.00");
|
||||
|
||||
t.section("delete, then ask for it again");
|
||||
Resp d = show(t, "DELETE", "/api/books/3", null);
|
||||
Resp gone = show(t, "GET", "/api/books/3", null);
|
||||
assertThat(d.status()).isEqualTo(204);
|
||||
assertThat(gone.status()).isEqualTo(404);
|
||||
|
||||
t.section("what is in the table now (read with SQL, not through the API)");
|
||||
jdbc.queryForList("SELECT id, title, price FROM book ORDER BY id")
|
||||
.forEach(row -> t.line("id=%s title=%s price=%s", row.get("id"), row.get("title"), row.get("price")));
|
||||
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM book", Integer.class)).isEqualTo(2);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 03
|
||||
@Test @Order(3)
|
||||
void errors() throws Exception {
|
||||
try (Transcript t = new Transcript("03-error-responses.txt", "What the client sees when something is wrong")) {
|
||||
t.section("validation: three fields wrong at once");
|
||||
Resp v = show(t, "POST", "/api/books", "{\"title\":\"\",\"author\":\"Someone\",\"isbn\":\"123\",\"price\":-1}");
|
||||
assertThat(v.status()).isEqualTo(400);
|
||||
assertThat(v.body()).contains("\"errors\"").contains("isbn").contains("price").contains("title");
|
||||
|
||||
t.section("duplicate ISBN on create");
|
||||
Resp dup = show(t, "POST", "/api/books", JCIP);
|
||||
assertThat(dup.status()).isEqualTo(409);
|
||||
|
||||
t.section("duplicate ISBN on update (book 2 takes book 1's ISBN)");
|
||||
Resp dupPut = show(t, "PUT", "/api/books/2",
|
||||
"{\"title\":\"Java Concurrency in Practice\",\"author\":\"Brian Goetz\",\"isbn\":\"9780134685991\",\"price\":39.99}");
|
||||
assertThat(dupPut.status()).isEqualTo(409);
|
||||
|
||||
t.section("the message MySQL itself produced for the duplicate");
|
||||
try {
|
||||
jdbc.update("INSERT INTO book(title, author, isbn, price) VALUES ('x','y','9780134685991', 1)");
|
||||
Assertions.fail("insert should have failed");
|
||||
} catch (DataIntegrityViolationException e) {
|
||||
t.line("%s", e.getMostSpecificCause().getMessage());
|
||||
assertThat(e.getMostSpecificCause().getMessage()).contains("Duplicate entry").contains("uk_book_isbn");
|
||||
}
|
||||
|
||||
t.section("unknown id");
|
||||
assertThat(show(t, "GET", "/api/books/99", null).status()).isEqualTo(404);
|
||||
assertThat(show(t, "DELETE", "/api/books/99", null).status()).isEqualTo(404);
|
||||
|
||||
t.section("the id is not a number");
|
||||
assertThat(show(t, "GET", "/api/books/abc", null).status()).isEqualTo(400);
|
||||
|
||||
t.section("the body is not JSON");
|
||||
assertThat(show(t, "POST", "/api/books", "{not json").status()).isEqualTo(400);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ 04
|
||||
@Test @Order(4)
|
||||
void sqlPerRequest() throws Exception {
|
||||
try (Transcript t = new Transcript("04-sql-per-request.txt", "The SQL Hibernate sent for each request (recorded by a StatementInspector)")) {
|
||||
record Step(String label, String method, String path, String json) {}
|
||||
for (Step s : List.of(
|
||||
new Step("create", "POST", "/api/books", CLEAN),
|
||||
new Step("read one", "GET", "/api/books/1", null),
|
||||
new Step("list page 0, size 2", "GET", "/api/books?page=0&size=2", null),
|
||||
new Step("list filtered by author", "GET", "/api/books?author=bloch", null),
|
||||
new Step("update", "PUT", "/api/books/1",
|
||||
"{\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"isbn\":\"9780134685991\",\"price\":40.00}"),
|
||||
new Step("delete", "DELETE", "/api/books/1", null))) {
|
||||
SqlRecorder.SQL.clear();
|
||||
Resp r = call(s.method(), s.path(), s.json());
|
||||
t.section(s.label() + ": " + s.method() + " " + s.path() + " -> HTTP " + r.status());
|
||||
for (String sql : SqlRecorder.SQL) t.line(sql);
|
||||
assertThat(SqlRecorder.SQL).isNotEmpty();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.ankurm.crud;
|
||||
|
||||
import org.hibernate.resource.jdbc.spi.StatementInspector;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
|
||||
/** Records every SQL statement Hibernate sends, so the transcript can show what each HTTP call really ran. */
|
||||
public class SqlRecorder implements StatementInspector {
|
||||
public static final List<String> SQL = new CopyOnWriteArrayList<>();
|
||||
|
||||
@Override
|
||||
public String inspect(String sql) {
|
||||
SQL.add(sql);
|
||||
return sql;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.ankurm.crud;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Writes a numbered transcript under {@code output/} and echoes it to the console.
|
||||
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||
*/
|
||||
public final class Transcript implements AutoCloseable {
|
||||
|
||||
private final Path path;
|
||||
private final StringWriter buffer = new StringWriter();
|
||||
private final PrintWriter out = new PrintWriter(buffer);
|
||||
|
||||
public Transcript(String fileName, String title) {
|
||||
this.path = Path.of("output", fileName);
|
||||
out.println("# " + title);
|
||||
out.println();
|
||||
}
|
||||
|
||||
public Transcript line(String format, Object... args) {
|
||||
out.println(args.length == 0 ? format : String.format(format, args));
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript blank() {
|
||||
out.println();
|
||||
return this;
|
||||
}
|
||||
|
||||
public Transcript section(String heading) {
|
||||
out.println();
|
||||
out.println("--- " + heading + " ---");
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
out.flush();
|
||||
// Absolute paths of whoever ran the build are environment noise, not a finding.
|
||||
String text = buffer.toString().replace(System.getProperty("user.dir"), "<crud-mysql>");
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, text);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("could not write " + path, e);
|
||||
}
|
||||
System.out.print(text);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user