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:
Claude
2026-10-21 16:43:45 +00:00
parent ba709737de
commit 2996628ff1
27 changed files with 968 additions and 0 deletions
@@ -0,0 +1,11 @@
package com.ankurm.crud;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CrudApplication {
public static void main(String[] args) {
SpringApplication.run(CrudApplication.class, args);
}
}
@@ -0,0 +1,49 @@
package com.ankurm.crud.book;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "book")
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // MySQL AUTO_INCREMENT
private Long id;
@Column(nullable = false, length = 200)
private String title;
@Column(nullable = false, length = 120)
private String author;
@Column(nullable = false, length = 13, unique = true)
private String isbn;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
protected Book() { // required by JPA
}
public Book(String title, String author, String isbn, BigDecimal price) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.price = price;
}
public void change(String title, String author, String isbn, BigDecimal price) {
this.title = title;
this.author = author;
this.isbn = isbn;
this.price = price;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public String getAuthor() { return author; }
public String getIsbn() { return isbn; }
public BigDecimal getPrice() { return price; }
}
@@ -0,0 +1,48 @@
package com.ankurm.crud.book;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import java.net.URI;
@RestController
@RequestMapping("/api/books")
public class BookController {
private final BookService service;
public BookController(BookService service) {
this.service = service;
}
@GetMapping
public PageResponse<BookResponse> list(@RequestParam(required = false) String author,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "5") int size) {
return service.list(author, page, size);
}
@GetMapping("/{id}")
public BookResponse get(@PathVariable Long id) {
return service.get(id);
}
@PostMapping
public ResponseEntity<BookResponse> create(@Valid @RequestBody BookRequest request) {
BookResponse created = service.create(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}").buildAndExpand(created.id()).toUri();
return ResponseEntity.created(location).body(created);
}
@PutMapping("/{id}")
public BookResponse update(@PathVariable Long id, @Valid @RequestBody BookRequest request) {
return service.update(id, request);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
service.delete(id);
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,7 @@
package com.ankurm.crud.book;
public class BookNotFoundException extends RuntimeException {
public BookNotFoundException(Long id) {
super("Book " + id + " not found");
}
}
@@ -0,0 +1,10 @@
package com.ankurm.crud.book;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface BookRepository extends JpaRepository<Book, Long> {
Page<Book> findByAuthorContainingIgnoreCase(String author, Pageable pageable);
}
@@ -0,0 +1,17 @@
package com.ankurm.crud.book;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
/** What a client may send. The id is never part of it: the database assigns it. */
public record BookRequest(
@NotBlank @Size(max = 200) String title,
@NotBlank @Size(max = 120) String author,
@NotBlank @Pattern(regexp = "97[89]\\d{10}", message = "must be 13 digits starting with 978 or 979") String isbn,
@NotNull @DecimalMin(value = "0.00") BigDecimal price) {
}
@@ -0,0 +1,10 @@
package com.ankurm.crud.book;
import java.math.BigDecimal;
public record BookResponse(Long id, String title, String author, String isbn, BigDecimal price) {
static BookResponse from(Book b) {
return new BookResponse(b.getId(), b.getTitle(), b.getAuthor(), b.getIsbn(), b.getPrice());
}
}
@@ -0,0 +1,52 @@
package com.ankurm.crud.book;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class BookService {
private static final int MAX_PAGE_SIZE = 50;
private final BookRepository repository;
public BookService(BookRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public PageResponse<BookResponse> list(String author, int page, int size) {
PageRequest pageable = PageRequest.of(Math.max(page, 0), Math.min(Math.max(size, 1), MAX_PAGE_SIZE), Sort.by("id"));
var result = (author == null || author.isBlank())
? repository.findAll(pageable)
: repository.findByAuthorContainingIgnoreCase(author, pageable);
return PageResponse.of(result.map(BookResponse::from));
}
@Transactional(readOnly = true)
public BookResponse get(Long id) {
return BookResponse.from(find(id));
}
@Transactional
public BookResponse create(BookRequest r) {
return BookResponse.from(repository.save(new Book(r.title(), r.author(), r.isbn(), r.price())));
}
@Transactional
public BookResponse update(Long id, BookRequest r) {
Book book = find(id);
book.change(r.title(), r.author(), r.isbn(), r.price()); // dirty checking issues the UPDATE at commit
return BookResponse.from(book);
}
@Transactional
public void delete(Long id) {
repository.delete(find(id));
}
private Book find(Long id) {
return repository.findById(id).orElseThrow(() -> new BookNotFoundException(id));
}
}
@@ -0,0 +1,16 @@
package com.ankurm.crud.book;
import org.springframework.data.domain.Page;
import java.util.List;
/**
* Our own page shape. Returning Spring Data's {@code Page} straight from a controller is discouraged,
* because its JSON layout is an implementation detail rather than a contract.
*/
public record PageResponse<T>(List<T> content, int page, int size, long totalElements, int totalPages) {
static <T> PageResponse<T> of(Page<T> p) {
return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages());
}
}
@@ -0,0 +1,51 @@
package com.ankurm.crud.error;
import com.ankurm.crud.book.BookNotFoundException;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* One place that turns exceptions into RFC 9457 problem documents.
* ResponseEntityExceptionHandler already maps Spring MVC's own exceptions (bad JSON, bad path variable...)
* to ProblemDetail; we add the ones that belong to this application.
*/
@RestControllerAdvice
public class ApiExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(BookNotFoundException.class)
ProblemDetail notFound(BookNotFoundException e) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, e.getMessage());
}
@ExceptionHandler(DataIntegrityViolationException.class)
ProblemDetail conflict(DataIntegrityViolationException e) {
String cause = String.valueOf(e.getMostSpecificCause().getMessage());
String detail = cause.contains("uk_book_isbn") ? "A book with that ISBN already exists" : "The change conflicts with existing data";
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, detail);
}
/** The default 400 says "Invalid request content." and nothing else. Add which field failed and why. */
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers,
HttpStatusCode status, WebRequest request) {
Map<String, String> errors = new LinkedHashMap<>();
ex.getBindingResult().getFieldErrors().stream()
.sorted((a, b) -> a.getField().compareTo(b.getField()))
.forEach(f -> errors.putIfAbsent(f.getField(), f.getDefaultMessage()));
ProblemDetail body = ex.getBody();
body.setProperty("errors", errors);
return handleExceptionInternal(ex, body, headers, status, request);
}
}
@@ -0,0 +1,10 @@
# Connection: override any of these with environment variables, no code change needed.
spring.datasource.url=${DB_URL:jdbc:mysql://localhost:3306/library}
spring.datasource.username=${DB_USER:library}
spring.datasource.password=${DB_PASSWORD:library}
# Flyway owns the schema. Hibernate only checks that the entity matches it and refuses to start if not.
spring.jpa.hibernate.ddl-auto=validate
# Do not keep a database session open while the view is rendered; the REST layer does not need it.
spring.jpa.open-in-view=false
@@ -0,0 +1,9 @@
CREATE TABLE book (
id BIGINT NOT NULL AUTO_INCREMENT,
title VARCHAR(200) NOT NULL,
author VARCHAR(120) NOT NULL,
isbn VARCHAR(13) NOT NULL,
price DECIMAL(10,2) NOT NULL,
PRIMARY KEY (id),
CONSTRAINT uk_book_isbn UNIQUE (isbn)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;
@@ -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);
}
}