From 2996628ff1f1e27be9c73fc528c218e18b9c1482 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:41:07 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01HhW8LD8r3EigoBaCGrQZx5 --- README.md | 1 + crud-mysql/README.md | 68 ++++++ crud-mysql/docker-compose.yml | 22 ++ crud-mysql/output/01-mysql-and-schema.txt | 22 ++ crud-mysql/output/02-crud-walkthrough.txt | 57 +++++ crud-mysql/output/03-error-responses.txt | 45 ++++ crud-mysql/output/04-sql-per-request.txt | 23 ++ crud-mysql/output/05-real-curl.txt | 32 +++ crud-mysql/pom.xml | 65 ++++++ crud-mysql/scripts/curl-demo.sh | 32 +++ crud-mysql/scripts/mysql-setup.sql | 5 + crud-mysql/scripts/run-all.sh | 17 ++ .../java/com/ankurm/crud/CrudApplication.java | 11 + .../main/java/com/ankurm/crud/book/Book.java | 49 ++++ .../com/ankurm/crud/book/BookController.java | 48 ++++ .../crud/book/BookNotFoundException.java | 7 + .../com/ankurm/crud/book/BookRepository.java | 10 + .../com/ankurm/crud/book/BookRequest.java | 17 ++ .../com/ankurm/crud/book/BookResponse.java | 10 + .../com/ankurm/crud/book/BookService.java | 52 +++++ .../com/ankurm/crud/book/PageResponse.java | 16 ++ .../crud/error/ApiExceptionHandler.java | 51 ++++ .../src/main/resources/application.properties | 10 + .../db/migration/V1__create_book.sql | 9 + .../java/com/ankurm/crud/CrudMysqlTests.java | 218 ++++++++++++++++++ .../java/com/ankurm/crud/SqlRecorder.java | 17 ++ .../test/java/com/ankurm/crud/Transcript.java | 54 +++++ 27 files changed, 968 insertions(+) create mode 100644 crud-mysql/README.md create mode 100644 crud-mysql/docker-compose.yml create mode 100644 crud-mysql/output/01-mysql-and-schema.txt create mode 100644 crud-mysql/output/02-crud-walkthrough.txt create mode 100644 crud-mysql/output/03-error-responses.txt create mode 100644 crud-mysql/output/04-sql-per-request.txt create mode 100644 crud-mysql/output/05-real-curl.txt create mode 100644 crud-mysql/pom.xml create mode 100755 crud-mysql/scripts/curl-demo.sh create mode 100644 crud-mysql/scripts/mysql-setup.sql create mode 100755 crud-mysql/scripts/run-all.sh create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/Book.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java create mode 100644 crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java create mode 100644 crud-mysql/src/main/resources/application.properties create mode 100644 crud-mysql/src/main/resources/db/migration/V1__create_book.sql create mode 100644 crud-mysql/src/test/java/com/ankurm/crud/CrudMysqlTests.java create mode 100644 crud-mysql/src/test/java/com/ankurm/crud/SqlRecorder.java create mode 100644 crud-mysql/src/test/java/com/ankurm/crud/Transcript.java diff --git a/README.md b/README.md index de45c9f..74ccd35 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/crud-mysql/README.md b/crud-mysql/README.md new file mode 100644 index 0000000..45a923e --- /dev/null +++ b/crud-mysql/README.md @@ -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` | diff --git a/crud-mysql/docker-compose.yml b/crud-mysql/docker-compose.yml new file mode 100644 index 0000000..f37111c --- /dev/null +++ b/crud-mysql/docker-compose.yml @@ -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 diff --git a/crud-mysql/output/01-mysql-and-schema.txt b/crud-mysql/output/01-mysql-and-schema.txt new file mode 100644 index 0000000..604c342 --- /dev/null +++ b/crud-mysql/output/01-mysql-and-schema.txt @@ -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 diff --git a/crud-mysql/output/02-crud-walkthrough.txt b/crud-mysql/output/02-crud-walkthrough.txt new file mode 100644 index 0000000..6a0f340 --- /dev/null +++ b/crud-mysql/output/02-crud-walkthrough.txt @@ -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 diff --git a/crud-mysql/output/03-error-responses.txt b/crud-mysql/output/03-error-responses.txt new file mode 100644 index 0000000..279d179 --- /dev/null +++ b/crud-mysql/output/03-error-responses.txt @@ -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"} + diff --git a/crud-mysql/output/04-sql-per-request.txt b/crud-mysql/output/04-sql-per-request.txt new file mode 100644 index 0000000..767353f --- /dev/null +++ b/crud-mysql/output/04-sql-per-request.txt @@ -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=? diff --git a/crud-mysql/output/05-real-curl.txt b/crud-mysql/output/05-real-curl.txt new file mode 100644 index 0000000..1f211ae --- /dev/null +++ b/crud-mysql/output/05-real-curl.txt @@ -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"} + + diff --git a/crud-mysql/pom.xml b/crud-mysql/pom.xml new file mode 100644 index 0000000..a11ae4b --- /dev/null +++ b/crud-mysql/pom.xml @@ -0,0 +1,65 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 4.1.1 + + + + com.ankurm + crud-mysql + 1.0.0 + crud-mysql + Spring Boot 4.1 CRUD REST API on MySQL 8.4 with Spring Data JPA, Flyway, validation and ProblemDetail + + + 21 + + + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-flyway + + + org.flywaydb + flyway-mysql + + + com.mysql + mysql-connector-j + runtime + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/crud-mysql/scripts/curl-demo.sh b/crud-mysql/scripts/curl-demo.sh new file mode 100755 index 0000000..21dbfec --- /dev/null +++ b/crud-mysql/scripts/curl-demo.sh @@ -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" diff --git a/crud-mysql/scripts/mysql-setup.sql b/crud-mysql/scripts/mysql-setup.sql new file mode 100644 index 0000000..f82fdf4 --- /dev/null +++ b/crud-mysql/scripts/mysql-setup.sql @@ -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'@'%'; diff --git a/crud-mysql/scripts/run-all.sh b/crud-mysql/scripts/run-all.sh new file mode 100755 index 0000000..1b45b17 --- /dev/null +++ b/crud-mysql/scripts/run-all.sh @@ -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 diff --git a/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java b/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java new file mode 100644 index 0000000..1351aa6 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/CrudApplication.java @@ -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); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java b/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java new file mode 100644 index 0000000..b22c038 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/Book.java @@ -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; } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java new file mode 100644 index 0000000..adc6a2e --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookController.java @@ -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 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 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 delete(@PathVariable Long id) { + service.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java new file mode 100644 index 0000000..09d3aac --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookNotFoundException.java @@ -0,0 +1,7 @@ +package com.ankurm.crud.book; + +public class BookNotFoundException extends RuntimeException { + public BookNotFoundException(Long id) { + super("Book " + id + " not found"); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java new file mode 100644 index 0000000..1516ee2 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRepository.java @@ -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 { + + Page findByAuthorContainingIgnoreCase(String author, Pageable pageable); +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java new file mode 100644 index 0000000..7ba6240 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookRequest.java @@ -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) { +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java new file mode 100644 index 0000000..4d4de0c --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookResponse.java @@ -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()); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java b/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java new file mode 100644 index 0000000..478e256 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/BookService.java @@ -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 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)); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java b/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java new file mode 100644 index 0000000..8fa6041 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/book/PageResponse.java @@ -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(List content, int page, int size, long totalElements, int totalPages) { + + static PageResponse of(Page p) { + return new PageResponse<>(p.getContent(), p.getNumber(), p.getSize(), p.getTotalElements(), p.getTotalPages()); + } +} diff --git a/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java b/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java new file mode 100644 index 0000000..8eb5d69 --- /dev/null +++ b/crud-mysql/src/main/java/com/ankurm/crud/error/ApiExceptionHandler.java @@ -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 handleMethodArgumentNotValid(MethodArgumentNotValidException ex, HttpHeaders headers, + HttpStatusCode status, WebRequest request) { + Map 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); + } +} diff --git a/crud-mysql/src/main/resources/application.properties b/crud-mysql/src/main/resources/application.properties new file mode 100644 index 0000000..5dafe32 --- /dev/null +++ b/crud-mysql/src/main/resources/application.properties @@ -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 diff --git a/crud-mysql/src/main/resources/db/migration/V1__create_book.sql b/crud-mysql/src/main/resources/db/migration/V1__create_book.sql new file mode 100644 index 0000000..d97723d --- /dev/null +++ b/crud-mysql/src/main/resources/db/migration/V1__create_book.sql @@ -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; diff --git a/crud-mysql/src/test/java/com/ankurm/crud/CrudMysqlTests.java b/crud-mysql/src/test/java/com/ankurm/crud/CrudMysqlTests.java new file mode 100644 index 0000000..5ac9131 --- /dev/null +++ b/crud-mysql/src/test/java/com/ankurm/crud/CrudMysqlTests.java @@ -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 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> 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(); + } + } + } +} diff --git a/crud-mysql/src/test/java/com/ankurm/crud/SqlRecorder.java b/crud-mysql/src/test/java/com/ankurm/crud/SqlRecorder.java new file mode 100644 index 0000000..82fddcb --- /dev/null +++ b/crud-mysql/src/test/java/com/ankurm/crud/SqlRecorder.java @@ -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 SQL = new CopyOnWriteArrayList<>(); + + @Override + public String inspect(String sql) { + SQL.add(sql); + return sql; + } +} diff --git a/crud-mysql/src/test/java/com/ankurm/crud/Transcript.java b/crud-mysql/src/test/java/com/ankurm/crud/Transcript.java new file mode 100644 index 0000000..ba536a2 --- /dev/null +++ b/crud-mysql/src/test/java/com/ankurm/crud/Transcript.java @@ -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"), ""); + try { + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } catch (IOException e) { + throw new IllegalStateException("could not write " + path, e); + } + System.out.print(text); + } +}