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:
@@ -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` |
|
||||
| [`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 |
|
||||
| [`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
|
||||
collapsible sections inside the articles themselves. Where their captured output sits is not
|
||||
|
||||
@@ -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` |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
|
||||
@@ -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=?
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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>
|
||||
Executable
+32
@@ -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"
|
||||
@@ -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'@'%';
|
||||
Executable
+17
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user