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
+1
View File
@@ -44,6 +44,7 @@ files.
| [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` | | [`rest-test-client/`](rest-test-client) | [RestTestClient in Spring Framework 7: Testing REST APIs End-to-End (vs MockMvcTester)](https://ankurm.com/resttestclient-spring-framework-7-end-to-end-testing/) | one fluent `RestTestClient` API proven across `bindToController`, `bindTo(mockMvc)`, `bindToApplicationContext` and `bindToServer`; `RestTestClient`, `TestRestTemplate` and `@WebMvcTest` confirmed relocated into new Spring Boot 4 modules by unzipping the real jars; two distinct real failures for the two now-separate `@AutoConfigureRestTestClient`/`@AutoConfigureTestRestTemplate` annotations; and the JDK `HttpClient` Boot picks for `bindToServer` caught transparently decoding gzip and hiding `Content-Encoding`, verified against `curl` and against the older `SimpleClientHttpRequestFactory` |
| [`file-transfer/`](file-transfer) | [File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/) | multipart limits enforced by Tomcat (and ignored by `MockMvc`), the client-controlled filename, `Range` downloads, a 200 MB download and 150 MB upload under `-Xmx64m`, and S3 presigned URLs checked against a local SigV4 verifier (not a real S3) | | [`file-transfer/`](file-transfer) | [File Upload and Download in Spring Boot 4: Multipart, Streaming and S3 Presigned URLs](https://ankurm.com/file-upload-download-spring-boot-4-multipart-streaming-s3-presigned-urls/) | multipart limits enforced by Tomcat (and ignored by `MockMvc`), the client-controlled filename, `Range` downloads, a 200 MB download and 150 MB upload under `-Xmx64m`, and S3 presigned URLs checked against a local SigV4 verifier (not a real S3) |
| [`project-structure/`](project-structure) | [Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/) | the same application written package-by-layer and package-by-feature with one HTTP API, the real source trees, 9 of 9 types public by layer vs 3 of 15 by feature, and the `javac` error that makes a feature boundary a compiler rule instead of a convention | | [`project-structure/`](project-structure) | [Spring Boot Project Structure: Package-by-Layer vs Package-by-Feature](https://ankurm.com/spring-boot-project-structure/) | the same application written package-by-layer and package-by-feature with one HTTP API, the real source trees, 9 of 9 types public by layer vs 3 of 15 by feature, and the `javac` error that makes a feature boundary a compiler rule instead of a convention |
| [`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 |
`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 `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 collapsible sections inside the articles themselves. Where their captured output sits is not
+68
View File
@@ -0,0 +1,68 @@
# crud-mysql
Companion project for the article **[Spring Boot CRUD REST API with MySQL and Spring Data JPA](https://ankurm.com/spring-boot-crud-rest-api-mysql/)** on **[ankurm.com](https://ankurm.com)**.
A complete Create / Read / Update / Delete API for books on **Spring Boot 4.1, Spring Data JPA (Hibernate),
Flyway and MySQL 8.4**, with Bean Validation and RFC 9457 `ProblemDetail` errors. Every console block
quoted in the article came out of `output/`, written by the test suite against a real MySQL server, so a
claim that stops being true turns the build red.
There is deliberately **no `docs/` folder**: the deeper material lives in collapsible sections inside the
article itself.
## Versions
| | |
|---|---|
| Spring Boot | 4.1.1 |
| Spring Framework | 7.0.9 |
| MySQL server used for `output/` | MySQL Community Server 8.4.6 (official Linux tarball, started directly; **not** Docker) |
| MySQL driver | MySQL Connector/J 9.7.0 (what Boot 4.1.1 manages) |
| JDK | 21 (OpenJDK 21.0.12) |
| Maven | 3.9 |
Only JDK 21 was used. `docker-compose.yml` was **not run** (the sandbox had no Docker).
## Quickstart
```bash
docker compose up -d # or run scripts/mysql-setup.sql against a MySQL you already have
mvn test # runs the scenarios and rewrites output/01-04
mvn spring-boot:run # API on http://localhost:8080/api/books
```
Defaults: `jdbc:mysql://localhost:3306/library`, user `library`, password `library`. Override with
`DB_URL`, `DB_USER` and `DB_PASSWORD`. The tests start the real application and delete all rows in the
`book` table first, so point them at a database you can wipe.
## Endpoints
| Method and path | What it does | Success | Errors |
|---|---|---|---|
| `POST /api/books` | create | `201` + `Location` | `400` validation, `409` duplicate ISBN |
| `GET /api/books/{id}` | read one | `200` | `404`, `400` (id not a number) |
| `GET /api/books?author=&page=&size=` | list, paged, optional author filter | `200` | |
| `PUT /api/books/{id}` | replace | `200` | `400`, `404`, `409` |
| `DELETE /api/books/{id}` | delete | `204` | `404` |
## Source layout
| Path | What it is |
|---|---|
| `book/Book.java` | the JPA entity, mapped to the `book` table |
| `book/BookRepository.java` | a Spring Data interface: no implementation to write |
| `book/BookService.java` | transactions, paging, dirty checking for the update |
| `book/BookController.java` | the HTTP layer |
| `book/BookRequest.java`, `BookResponse.java`, `PageResponse.java` | what crosses the wire |
| `error/ApiExceptionHandler.java` | every error becomes a `ProblemDetail` |
| `resources/db/migration/V1__create_book.sql` | the schema, owned by Flyway |
## Captured output
| File | What it shows |
|---|---|
| `01-mysql-and-schema.txt` | the server and driver versions, the Hibernate dialect, the Flyway history and `SHOW CREATE TABLE` |
| `02-crud-walkthrough.txt` | create, read, list (paged and filtered), update, delete: the requests are made with the JDK's `HttpClient` and printed as the equivalent curl commands, with the real responses |
| `03-error-responses.txt` | validation, duplicate ISBN on create and on update, the message MySQL itself returned, 404, bad id, bad JSON |
| `04-sql-per-request.txt` | the SQL Hibernate sent for each kind of request |
| `05-real-curl.txt` | real `curl -i` against the packaged jar (headers included), written by `scripts/curl-demo.sh` |
+22
View File
@@ -0,0 +1,22 @@
# A MySQL 8.4 server for the application's defaults (database library, user library, password library).
#
# docker compose up -d
#
# NOT RUN when this module was written: the sandbox had no Docker. The captured output in output/ came from
# MySQL Community Server 8.4.6 started directly from the official tarball with the same database, user and
# password. If the compose file fails on your machine, `scripts/mysql-setup.sql` does the same thing by hand.
services:
mysql:
image: mysql:8.4
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: rootpw
MYSQL_DATABASE: library
MYSQL_USER: library
MYSQL_PASSWORD: library
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "127.0.0.1", "-plibrary", "-ulibrary"]
interval: 5s
timeout: 3s
retries: 20
+22
View File
@@ -0,0 +1,22 @@
# What the application is connected to and what Flyway built
--- the server and the driver ---
database : MySQL 8.4.6
driver : MySQL Connector/J mysql-connector-j-9.7.0 (Revision: 0aade1f13bcc98faf7dda5c02e782481eb291f62)
dialect : MySQLDialect
ddl-auto : validate
--- flyway_schema_history ---
version=1 description=create book success=true
--- SHOW CREATE TABLE book ---
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`),
UNIQUE KEY `uk_book_isbn` (`isbn`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci
+57
View File
@@ -0,0 +1,57 @@
# 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.
--- create three books ---
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}'
HTTP 201 Location: http://localhost:8080/api/books/1
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}'
HTTP 201 Location: http://localhost:8080/api/books/2
{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}'
HTTP 201 Location: http://localhost:8080/api/books/3
{"id":3,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}
--- read one ---
$ curl -i -X GET 'localhost:8080/api/books/1'
HTTP 200
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
--- list, two per page ---
$ curl -i -X GET 'localhost:8080/api/books?page=0&size=2'
HTTP 200
{"content":[{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50},{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}],"page":0,"size":2,"totalElements":3,"totalPages":2}
$ curl -i -X GET 'localhost:8080/api/books?page=1&size=2'
HTTP 200
{"content":[{"id":3,"title":"Clean Code","author":"Robert Martin","isbn":"9780132350884","price":33.00}],"page":1,"size":2,"totalElements":3,"totalPages":2}
--- filter by author ---
$ curl -i -X GET 'localhost:8080/api/books?author=goetz'
HTTP 200
{"content":[{"id":2,"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}],"page":0,"size":5,"totalElements":1,"totalPages":1}
--- update the price ---
$ curl -i -X PUT 'localhost:8080/api/books/1' -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":42.00}'
HTTP 200
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":42.00}
--- delete, then ask for it again ---
$ curl -i -X DELETE 'localhost:8080/api/books/3'
HTTP 204
$ curl -i -X GET 'localhost:8080/api/books/3'
HTTP 404
{"detail":"Book 3 not found","instance":"/api/books/3","status":404,"title":"Not Found"}
--- what is in the table now (read with SQL, not through the API) ---
id=1 title=Effective Java price=42.00
id=2 title=Java Concurrency in Practice price=39.99
+45
View File
@@ -0,0 +1,45 @@
# What the client sees when something is wrong
--- validation: three fields wrong at once ---
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"","author":"Someone","isbn":"123","price":-1}'
HTTP 400
{"detail":"Invalid request content.","instance":"/api/books","status":400,"title":"Bad Request","errors":{"isbn":"must be 13 digits starting with 978 or 979","price":"must be greater than or equal to 0.00","title":"must not be blank"}}
--- duplicate ISBN on create ---
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780321349606","price":39.99}'
HTTP 409
{"detail":"A book with that ISBN already exists","instance":"/api/books","status":409,"title":"Conflict"}
--- duplicate ISBN on update (book 2 takes book 1's ISBN) ---
$ curl -i -X PUT 'localhost:8080/api/books/2' -H 'Content-Type: application/json' -d '{"title":"Java Concurrency in Practice","author":"Brian Goetz","isbn":"9780134685991","price":39.99}'
HTTP 409
{"detail":"A book with that ISBN already exists","instance":"/api/books/2","status":409,"title":"Conflict"}
--- the message MySQL itself produced for the duplicate ---
Duplicate entry '9780134685991' for key 'book.uk_book_isbn'
--- unknown id ---
$ curl -i -X GET 'localhost:8080/api/books/99'
HTTP 404
{"detail":"Book 99 not found","instance":"/api/books/99","status":404,"title":"Not Found"}
$ curl -i -X DELETE 'localhost:8080/api/books/99'
HTTP 404
{"detail":"Book 99 not found","instance":"/api/books/99","status":404,"title":"Not Found"}
--- the id is not a number ---
$ curl -i -X GET 'localhost:8080/api/books/abc'
HTTP 400
{"detail":"Failed to convert 'id' with value: 'abc'","instance":"/api/books/abc","status":400,"title":"Bad Request"}
--- the body is not JSON ---
$ curl -i -X POST 'localhost:8080/api/books' -H 'Content-Type: application/json' -d '{not json'
HTTP 400
{"detail":"Failed to read request","instance":"/api/books","status":400,"title":"Bad Request"}
+23
View File
@@ -0,0 +1,23 @@
# The SQL Hibernate sent for each request (recorded by a StatementInspector)
--- create: POST /api/books -> HTTP 201 ---
insert into book (author,isbn,price,title) values (?,?,?,?)
--- read one: GET /api/books/1 -> HTTP 200 ---
select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
--- list page 0, size 2: GET /api/books?page=0&size=2 -> HTTP 200 ---
select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 order by b1_0.id limit ?,?
select count(b1_0.id) from book b1_0
--- list filtered by author: GET /api/books?author=bloch -> HTTP 200 ---
select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where upper(b1_0.author) like upper(?) escape '\\' order by b1_0.id limit ?
--- update: PUT /api/books/1 -> HTTP 200 ---
select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
update book set author=?,isbn=?,price=?,title=? where id=?
--- delete: DELETE /api/books/1 -> HTTP 204 ---
select b1_0.id,b1_0.author,b1_0.isbn,b1_0.price,b1_0.title from book b1_0 where b1_0.id=?
delete from book where id=?
+32
View File
@@ -0,0 +1,32 @@
# Real curl -i output against the packaged jar (java -jar target/crud-mysql-1.0.0.jar), Date header removed
$ curl -i -X POST localhost:8080/api/books -H 'Content-Type: application/json' -d '{"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}'
HTTP/1.1 201
Location: http://localhost:8080/api/books/1
Content-Type: application/json
Transfer-Encoding: chunked
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
$ curl -i localhost:8080/api/books/1
HTTP/1.1 200
Content-Type: application/json
Content-Length: 94
{"id":1,"title":"Effective Java","author":"Joshua Bloch","isbn":"9780134685991","price":45.50}
$ curl -i -X DELETE localhost:8080/api/books/1
HTTP/1.1 204
$ curl -i localhost:8080/api/books/1
HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
{"detail":"Book 1 not found","instance":"/api/books/1","status":404,"title":"Not Found"}
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.ankurm</groupId>
<artifactId>crud-mysql</artifactId>
<version>1.0.0</version>
<name>crud-mysql</name>
<description>Spring Boot 4.1 CRUD REST API on MySQL 8.4 with Spring Data JPA, Flyway, validation and ProblemDetail</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-flyway</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-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>
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Starts the packaged jar, makes real curl -i calls against it and stops it again. Raw output, headers included,
# goes to output/05-real-curl.txt.
#
# ./scripts/curl-demo.sh
#
# Needs the same MySQL as the tests, and wipes the book table first.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B -q package -DskipTests
java -jar target/crud-mysql-1.0.0.jar --server.port=8080 --logging.level.root=WARN > target/app.log 2>&1 &
PID=$!
trap 'kill $PID 2>/dev/null || true' EXIT
for _ in $(seq 1 60); do curl -s -o /dev/null localhost:8080/api/books && break; sleep 1; done
OUT=output/05-real-curl.txt
H='Content-Type: application/json'
{
echo "# Real curl -i output against the packaged jar (java -jar target/crud-mysql-1.0.0.jar), Date header removed"
echo
for cmd in \
"curl -i -s -X POST localhost:8080/api/books -H '$H' -d '{\"title\":\"Effective Java\",\"author\":\"Joshua Bloch\",\"isbn\":\"9780134685991\",\"price\":45.50}'" \
"curl -i -s localhost:8080/api/books/1" \
"curl -i -s -X DELETE localhost:8080/api/books/1" \
"curl -i -s localhost:8080/api/books/1"; do
echo "\$ ${cmd/ -s/}"
eval "$cmd" | tr -d '\r' | grep -v '^Date:'
echo; echo
done
} > "$OUT"
cat "$OUT"
+5
View File
@@ -0,0 +1,5 @@
-- Creates the database and the user the application expects. Run as a MySQL administrator:
-- mysql -uroot -p < scripts/mysql-setup.sql
CREATE DATABASE IF NOT EXISTS library;
CREATE USER IF NOT EXISTS 'library'@'%' IDENTIFIED BY 'library';
GRANT ALL ON library.* TO 'library'@'%';
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
# Regenerates every file under output/.
#
# ./scripts/run-all.sh
#
# Needs a JDK 21 or newer, Maven 3.9 and a MySQL 8.4 server reachable with the defaults in
# src/main/resources/application.properties (localhost:3306, database library, user library, password
# library), or the same values in DB_URL / DB_USER / DB_PASSWORD. All four transcripts are written by the
# test suite, so every figure in the article is an assertion that fails the build if it stops being true.
set -euo pipefail
cd "$(dirname "$0")/.."
mvn -B test
echo
echo "output:"
ls -1 output
@@ -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);
}
}