Bốn mươi mốt bài đã dựng một catalogue theo từng mảnh, mỗi mảnh trong một project riêng: endpoint ở Chương 3, JPA và Flyway ở Chương 4, user và JWT ở Chương 5, test ở Chương 6, Actuator và Docker ở Chương 7. Bài tổng kết này ghép mọi mảnh vào một ứng dụng, một REST API quản lý đơn hàng: khách hàng đăng ký và đăng nhập, xem catalogue product có phân trang, đặt order và order trừ stock, hủy order, còn administrator thì ship order. Ứng dụng chạy trên H2 khi phát triển và trên PostgreSQL 18 trong Docker Compose, tự sinh tài liệu trong Swagger UI, và có test ở mọi tầng.
Ghép các tính năng lại là lúc vấn đề mới xuất hiện, nên bài này không giảng lại những gì các bài trước đã giải thích; nó trỏ về các bài đó bằng số thứ tự và dành chữ cho các tương tác: stock bị bán quá khi có request đồng thời cho tới khi row được lock, một tham số Swagger UI bị Pageable làm hỏng, một @DataJpaTest fail khi thiếu cấu hình auditing, một check constraint của H2 ngừng hoạt động sau khi connection của Flyway đóng, và một private key phải tới được container mà không nằm trong image.
![]()
Dự án dùng Spring Boot 4.1.1, Java 21 và springdoc-openapi 3.1.1, với H2 cho môi trường phát triển và test, và PostgreSQL 18 trong Docker Compose. Request được gửi bằng curl và jq, và ứng dụng lắng nghe trên host port 8142 thay vì 8080 mặc định.
Dự án tổng kết gồm những gì và mỗi phần được dạy ở bài nào
| Phần của project | Dùng gì | Được dạy ở |
|---|---|---|
| Endpoint, status code, URL cho action hủy order | bảng thiết kế mẫu, 400 so với 422, 409 cho trạng thái | bài 15 |
| DTO của request và validation | record với Bean Validation, @Valid | bài 18 và 19 |
| Response lỗi | ProblemDetail, ResponseEntityExceptionHandler, danh sách field cho 422, catch-all | bài 20 |
| Bố cục package và các tầng | package theo feature, controller map DTO, service giữ rule | bài 21 |
| Tài liệu API | springdoc-openapi 3.1.1, Swagger UI | bài 22 |
| Entity và repository | JpaRepository, derived query, @Query | bài 26 và 27 |
| Order và các line | @ManyToOne(fetch = LAZY), mappedBy, cascade, orphanRemoval, @EntityGraph | bài 28 |
| Danh sách có phân trang | Pageable, @PageableDefault, PageResponse, max-page-size | bài 29 |
| Order được lưu trọn vẹn hoặc không gì cả | @Transactional trên use case | bài 30 |
| Schema | migration Flyway, ddl-auto=validate trên PostgreSQL | bài 31 |
| Timestamp audit | AuditableEntity, @EnableJpaAuditing, DateTimeProvider | bài 32 |
| Tài khoản và password | bảng users, BCrypt, UserDetailsService, đăng ký | bài 34 |
| Token | cặp RSA key, NimbusJwtEncoder, resource server, roles thành ROLE_ | bài 35 |
| Authorization | URL rule, @EnableMethodSecurity, @PreAuthorize, rethrow AccessDeniedException | bài 36 |
| Unit test | JUnit 6, AssertJ, Mockito | bài 37 |
| Test slice và test end-to-end | @WebMvcTest, @DataJpaTest, RANDOM_PORT với RestTestClient | bài 38 |
| Healthcheck | Actuator /actuator/health | bài 39 |
| Container | Dockerfile multi-stage, Compose với PostgreSQL và healthcheck | bài 41 |
| Cấu hình | profile, environment variable | bài 11 và 13 |

Tạo project
Các id của Spring Initializr giống các bài trước, thêm springdoc-openapi và actuator:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=orders&name=orders&packageName=com.example.orders&dependencies=web,validation,data-jpa,postgresql,h2,flyway,security,oauth2-resource-server,actuator,springdoc-openapi" -o orders.zipunzip orders.zip -d ordersbuild.gradle được sinh ra có đủ các starter và test starter, kể cả spring-boot-starter-actuator-test, cùng springdoc-openapi-starter-webmvc-ui:3.1.0. Bốn thay đổi biến nó thành file build của dự án tổng kết:
- springdoc 3.1.1 thay cho 3.1.0, để có Swagger UI đã vá lỗi DOMPurify, như bài 22 khuyên.
- Mockito agent của bài 37, để JVM chạy test nạp Mockito bằng
-javaagentthay vì tự attach. testLoggingvớipassed,skippedvàfailed, để console liệt kê từng test.- Tắt task
jarthường, điều mà lệnhCOPY build/libs/*.jartrongDockerfilecủa bài 41 dựa vào.
plugins {
id 'java'
id 'org.springframework.boot' version '4.1.1'
id 'io.spring.dependency-management' version '1.1.7'
}
group = 'com.example'
version = '0.0.1-SNAPSHOT'
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
configurations {
mockitoAgent
}
repositories {
mavenCentral()
}
dependencies {
implementation 'org.springframework.boot:spring-boot-h2console'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-flyway'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
implementation 'org.flywaydb:flyway-database-postgresql'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:3.1.1'
runtimeOnly 'com.h2database:h2'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-actuator-test'
testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
testImplementation 'org.springframework.boot:spring-boot-starter-flyway-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-oauth2-resource-server-test'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
mockitoAgent('org.mockito:mockito-core') {
transitive = false
}
}
tasks.named('test') {
useJUnitPlatform()
jvmArgs += "-javaagent:${configurations.mockitoAgent.asPath}"
testLogging {
events 'passed', 'skipped', 'failed'
}
}
tasks.named('jar') {
enabled = false
}RSA key nằm ngoài cây source
Bài 35 sinh cặp key vào src/main/resources/certs, ổn cho một bài lab nhưng sai với bất cứ thứ gì được build thành image: mọi thứ dưới src/main/resources đều vào JAR. Ở đây key nằm trong thư mục secrets ở gốc project, nơi cả Git lẫn Docker đều không thấy:
mkdir -p secrets
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out secrets/private.pem
openssl pkey -in secrets/private.pem -pubout -out secrets/public.pemprintf '\n### Local secrets ###\nsecrets/\n.env\n' >> .gitignoreỨng dụng đọc key qua location file: tính từ thư mục làm việc, chính là gốc project khi chạy ./gradlew bootRun, ./gradlew test và java -jar build/libs/orders-0.0.1-SNAPSHOT.jar từ đó. Phần Docker cho container một bản riêng lúc chạy.
Cây thư mục của project
orders
├── build.gradle
├── compose.yaml
├── Dockerfile
├── .dockerignore
├── .env not committed: APP_ADMIN_PASSWORD
├── secrets not committed: private.pem, public.pem
└── src
├── main
│ ├── java/com/example/orders
│ │ ├── OrdersApplication.java
│ │ ├── common
│ │ │ ├── AuditableEntity.java
│ │ │ ├── AuditingConfig.java
│ │ │ ├── AuthConfig.java
│ │ │ ├── ConflictException.java
│ │ │ ├── GlobalExceptionHandler.java
│ │ │ ├── MethodSecurityConfig.java
│ │ │ ├── NotFoundException.java
│ │ │ ├── OpenApiConfig.java
│ │ │ ├── PageResponse.java
│ │ │ ├── ProblemDetailSecurityHandler.java
│ │ │ └── SecurityConfig.java
│ │ ├── order
│ │ │ ├── Order.java
│ │ │ ├── OrderAccess.java
│ │ │ ├── OrderController.java
│ │ │ ├── OrderItem.java
│ │ │ ├── OrderLine.java
│ │ │ ├── OrderNotFoundException.java
│ │ │ ├── OrderRepository.java
│ │ │ ├── OrderResponse.java
│ │ │ ├── OrderService.java
│ │ │ ├── OrderStatus.java
│ │ │ ├── OrderStatusException.java
│ │ │ └── PlaceOrderRequest.java
│ │ ├── product
│ │ │ ├── CreateProductRequest.java
│ │ │ ├── DuplicateSkuException.java
│ │ │ ├── InsufficientStockException.java
│ │ │ ├── Product.java
│ │ │ ├── ProductController.java
│ │ │ ├── ProductNotFoundException.java
│ │ │ ├── ProductRepository.java
│ │ │ ├── ProductResponse.java
│ │ │ └── ProductService.java
│ │ └── user
│ │ ├── AdminAccountInitializer.java
│ │ ├── AppUser.java
│ │ ├── AppUserRepository.java
│ │ ├── AuthController.java
│ │ ├── JpaUserDetailsService.java
│ │ ├── LoginRequest.java
│ │ ├── RegisterRequest.java
│ │ ├── Role.java
│ │ ├── TokenResponse.java
│ │ ├── TokenService.java
│ │ ├── UserAlreadyExistsException.java
│ │ ├── UserResponse.java
│ │ └── UserService.java
│ └── resources
│ ├── application.properties
│ ├── application-postgres.properties
│ └── db/migration
│ ├── V1__create_users.sql
│ ├── V2__create_products.sql
│ └── V3__create_orders.sql
└── test/java/com/example/orders
├── OrdersApplicationTests.java
└── order
├── OrderApiTest.java
├── OrderControllerTest.java
├── OrderRepositoryTest.java
└── OrderServiceTest.javaOrdersApplication và OrdersApplicationTests là của Initializr, giữ nguyên. Hai thư mục static và templates mà Initializr tạo thì để trống.
API: endpoint, role và status code
Thiết kế của bài 15 đặt order dưới customer của nó, POST /api/customers/{id}/orders. Với JWT thì customer đã được xác định: subject của token cho biết ai đang gọi, nên một customer id trên URL chỉ có thể lặp lại thông tin đó, và mỗi request mà hai giá trị không khớp lại cần một bước kiểm tra với kết quả duy nhất là 403. Vì vậy dự án tổng kết đặt order bằng POST /api/orders cho user đã đăng nhập và giữ phần còn lại của thiết kế order ở bài 15: URL riêng của order mới trong Location, và hủy order là action POST /api/orders/{id}/cancel. Ship order theo đúng dạng đó như một action của administrator, còn order của người khác thì trả 403, như ở bài 36.
| Method | Path | Ai được gọi | Thành công | Lỗi |
|---|---|---|---|---|
POST | /api/auth/register | ai cũng được | 201 | 400, 409 username hoặc email đã có, 422 |
POST | /api/auth/login | ai cũng được | 200 kèm token | 400, 401 sai thông tin đăng nhập, 422 |
GET | /api/auth/me | đã đăng nhập | 200 | 401 |
GET | /api/products | ai cũng được | 200, một page; ?page=, ?size=, ?sort= | 400 sort property không tồn tại |
GET | /api/products/{id} | ai cũng được | 200 | 400, 404 |
POST | /api/products | ADMIN | 201 + Location | 400, 401, 403, 409 trùng SKU, 415, 422 |
POST | /api/orders | đã đăng nhập | 201 + Location: /api/orders/{orderId} | 400, 401, 404 product không tồn tại, 409 không đủ stock, 415, 422 |
GET | /api/orders | đã đăng nhập: order của mình; ADMIN: tất cả | 200, một page | 400, 401 |
GET | /api/orders/{id} | chủ order hoặc ADMIN | 200 | 400, 401, 403 order của người khác, 404 |
POST | /api/orders/{id}/cancel | chủ order hoặc ADMIN | 200 | 400, 401, 403, 404, 409 không còn PLACED |
POST | /api/orders/{id}/ship | ADMIN | 200 | 400, 401, 403, 404, 409 không còn PLACED |
Mọi status trong bảng đều đã được tạo ra từ ứng dụng hoàn chỉnh. Product không có PUT hay DELETE ở đây: order line tham chiếu tới product, và một endpoint nhập thêm hàng cho admin sẽ phải lấy cùng row lock mà việc đặt order lấy.
Cấu hình và migration Flyway
spring.application.name=orders
spring.jpa.open-in-view=false
spring.flyway.validate-migration-naming=true
spring.data.web.pageable.max-page-size=100
spring.security.oauth2.resourceserver.jwt.public-key-location=file:secrets/public.pem
spring.security.oauth2.resourceserver.jwt.authorities-claim-name=roles
spring.security.oauth2.resourceserver.jwt.authority-prefix=ROLE_
app.jwt.private-key-location=file:secrets/private.pem
app.jwt.issuer=https://orders.example.com
app.admin.username=admin
app.admin.email=admin@example.comopen-in-view=falsenhư mọi bài từ bài 26, để mỗi endpoint tự fetch những gì nó map.validate-migration-naming=truetừ bài 31, để một migration đặt sai tên làm dừng lúc khởi động thay vì bị bỏ qua.max-page-size=100từ bài 29.- Location của key dùng
file:thay choclasspath:của bài 35, vì lý do ở trên. app.jwt.issuerthay cho hằng số trongTokenServicecủa bài 35; cùng giá trị đó được dùng cho issuer validator.app.admin.*cấu hình administrator đầu tiên. Password không có giá trị mặc định; phần về user giải thích vì sao.
Profile postgres là của bài 41, với port của project này và không có password:
spring.datasource.url=jdbc:postgresql://localhost:5442/shop
spring.datasource.username=shop
spring.jpa.hibernate.ddl-auto=validateBa migration, mỗi feature một file, theo phong cách của bài 31: constraint có tên, index trên foreign key, NUMERIC(10, 2) cho tiền, TIMESTAMP WITH TIME ZONE cho các cột audit Instant của bài 32, và các check bảo vệ rule về stock ngay cả trước code không đi qua entity:
CREATE TABLE users (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
username VARCHAR(50) NOT NULL,
email VARCHAR(254) NOT NULL,
password_hash VARCHAR(100) NOT NULL,
role VARCHAR(20) NOT NULL,
enabled BOOLEAN NOT NULL,
CONSTRAINT uk_users_username UNIQUE (username),
CONSTRAINT uk_users_email UNIQUE (email)
);CREATE TABLE products (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name VARCHAR(120) NOT NULL,
sku VARCHAR(40) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT uk_products_sku UNIQUE (sku),
CONSTRAINT ck_products_price CHECK (price > 0),
CONSTRAINT ck_products_stock CHECK (stock >= 0)
);CREATE TABLE orders (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL,
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id) REFERENCES users (id)
);
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
CREATE TABLE order_lines (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
quantity INTEGER NOT NULL,
unit_price NUMERIC(10, 2) NOT NULL,
CONSTRAINT fk_order_lines_order FOREIGN KEY (order_id) REFERENCES orders (id),
CONSTRAINT fk_order_lines_product FOREIGN KEY (product_id) REFERENCES products (id),
CONSTRAINT ck_order_lines_quantity CHECK (quantity > 0)
);
CREATE INDEX idx_order_lines_order_id ON order_lines (order_id);
CREATE INDEX idx_order_lines_product_id ON order_lines (product_id);Vì sao role và status không có CHECK constraint
Phiên bản đầu của các migration này có CONSTRAINT ck_users_role CHECK (role IN ('USER', 'ADMIN')) và một check tương tự trên orders.status. Ứng dụng khởi động được trên H2 và PostgreSQL, và đăng ký chạy được trên cả hai. Rồi @DataJpaTest fail ngay lần persist user đầu tiên:
org.hibernate.exception.ConstraintViolationException: could not execute statement [Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
insert into users (email,enabled,password_hash,role,username,id) values (?,?,?,?,?,default) [23514-240]]
Caused by: org.h2.jdbc.JdbcSQLIntegrityConstraintViolationException: Check constraint invalid: "CK_USERS_ROLE: "; SQL statement:
Caused by: org.h2.message.DbException: The database has been closed [90098-240]Một chương trình JDBC thuần đã khoanh vùng vấn đề trên H2 2.4.240: một CHECK so sánh chuỗi, dù là IN (...) hay = 'USER' OR = 'ADMIN', vẫn chạy khi connection đã tạo bảng còn mở, và fail với đúng lỗi này trên một connection mới sau khi connection kia đóng; một check số như quantity > 0 thì vẫn chạy bình thường. Trong ứng dụng, connection của Flyway quay về pool HikariCP và vẫn mở, nên đăng ký chạy được; DataSource embedded mà @DataJpaTest thay vào thì trả về các org.h2.jdbc.JdbcConnection thuần, và một test thăm dò thấy isClosed() trả true sau close(), nên lúc test insert thì connection của Flyway đã đóng. Giá trị của cả hai cột đều đến từ enum Java qua @Enumerated(EnumType.STRING), nên dự án giữ các check số và bỏ hai check chuỗi, thay vì phụ thuộc vào việc connection nào tình cờ còn mở.
Code dùng chung trong common
Bài 21 gợi ý các feature ném subclass của vài exception gốc nằm trong common, để advice không phải import class của từng feature. Dự án tổng kết làm đúng như vậy, mỗi status một class gốc:
package com.example.orders.common;
public abstract class NotFoundException extends RuntimeException {
protected NotFoundException(String message) {
super(message);
}
}package com.example.orders.common;
public abstract class ConflictException extends RuntimeException {
protected ConflictException(String message) {
super(message);
}
}AuditableEntity là của bài 32 nhưng bỏ hai cột createdBy và updatedBy; order vốn đã ghi lại customer của nó:
package com.example.orders.common;
import java.time.Instant;
import jakarta.persistence.Column;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.MappedSuperclass;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
public abstract class AuditableEntity {
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
public Instant getCreatedAt() {
return createdAt;
}
public Instant getUpdatedAt() {
return updatedAt;
}
}AuditingConfig có DateTimeProvider của bài 32, với một thay đổi mà lần chạy Docker bắt buộc phải có:
package com.example.orders.common;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.auditing.DateTimeProvider;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing(dateTimeProviderRef = "auditingDateTimeProvider")
public class AuditingConfig {
@Bean
DateTimeProvider auditingDateTimeProvider() {
return () -> Optional.of(Instant.now().truncatedTo(ChronoUnit.MICROS));
}
}Với provider mặc định, một product tạo trong container được POST trả về với "createdAt":"2026-09-16T09:18:35.296391794Z", còn GET ngay sau đó trả "createdAt":"2026-09-16T09:18:35.296392Z". JVM trong container Linux đọc đồng hồ tới nano giây, JSON của POST được ghi từ giá trị đó, còn PostgreSQL lưu và trả về micro giây, đã làm tròn. Trên host macOS đồng hồ chỉ có micro giây, nên không thấy gì ở đó. Cắt xuống micro giây trước khi giá trị vào entity giúp hai response giống hệt nhau, 09:19:15.316336Z trong stack build lại.
PageResponse là record của bài 29, giữ nguyên, nằm trong com.example.orders.common. Advice là subclass ResponseEntityExceptionHandler của bài 20 cùng các handler mà những bài sau đã thêm: 409 cho DataIntegrityViolationException từ bài 26, 400 cho sort property không tồn tại từ bài 29, 401 cho đăng nhập thất bại từ bài 34 và 35, và rethrow AccessDeniedException từ bài 36:
package com.example.orders.common;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.core.PropertyReferenceException;
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.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.validation.FieldError;
import org.springframework.validation.method.ParameterErrors;
import org.springframework.validation.method.ParameterValidationResult;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
import org.springframework.web.method.annotation.HandlerMethodValidationException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
record FieldViolation(String field, String message) {
}
@ExceptionHandler(NotFoundException.class)
public ProblemDetail notFound(NotFoundException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
}
@ExceptionHandler(ConflictException.class)
public ProblemDetail conflict(ConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(DataIntegrityViolationException.class)
public ProblemDetail constraintViolated(DataIntegrityViolationException ex) {
log.warn("Constraint violation: {}", ex.getMostSpecificCause().getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, "The request conflicts with existing data.");
}
@ExceptionHandler(PropertyReferenceException.class)
public ProblemDetail unknownSortProperty(PropertyReferenceException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST,
"Unknown sort property: " + ex.getPropertyName());
}
@ExceptionHandler(AuthenticationException.class)
public ResponseEntity<ProblemDetail> authenticationFailed(AuthenticationException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
"Invalid username or password");
return ResponseEntity.of(problem)
.header(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"orders\"")
.build();
}
@ExceptionHandler(AccessDeniedException.class)
public void rethrowAccessDenied(AccessDeniedException ex) {
throw ex;
}
@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, HttpServletRequest request) {
log.error("Unhandled exception on {} {}", request.getMethod(), request.getRequestURI(), ex);
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred.");
problem.setTitle("Internal Server Error");
return problem;
}
@Override
protected ResponseEntity<Object> handleMethodArgumentNotValid(
MethodArgumentNotValidException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldViolation> errors = new ArrayList<>();
for (FieldError error : ex.getBindingResult().getFieldErrors()) {
errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
}
HttpStatus responseStatus = HttpStatus.UNPROCESSABLE_CONTENT;
return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
}
@Override
protected ResponseEntity<Object> handleHandlerMethodValidationException(
HandlerMethodValidationException ex, HttpHeaders headers, HttpStatusCode status, WebRequest request) {
List<FieldViolation> errors = new ArrayList<>();
boolean bodyInvalid = false;
for (ParameterErrors result : ex.getBeanResults()) {
if (result.getMethodParameter().hasParameterAnnotation(RequestBody.class)) {
bodyInvalid = true;
}
for (FieldError error : result.getFieldErrors()) {
errors.add(new FieldViolation(error.getField(), error.getDefaultMessage()));
}
}
for (ParameterValidationResult result : ex.getValueResults()) {
String name = result.getMethodParameter().getParameterName();
for (MessageSourceResolvable error : result.getResolvableErrors()) {
errors.add(new FieldViolation(name, error.getDefaultMessage()));
}
}
HttpStatus responseStatus = bodyInvalid ? HttpStatus.UNPROCESSABLE_CONTENT : HttpStatus.BAD_REQUEST;
return handleExceptionInternal(ex, validationProblem(responseStatus, errors), headers, responseStatus, request);
}
private ProblemDetail validationProblem(HttpStatus status, List<FieldViolation> errors) {
errors.sort(Comparator.comparing(FieldViolation::field));
ProblemDetail problem = ProblemDetail.forStatusAndDetail(
status, "Request has " + errors.size() + " invalid value(s).");
problem.setProperty("errors", errors);
return problem;
}
}Handler đăng nhập bắt AuthenticationException chứ không chỉ BadCredentialsException, nên DisabledException mà bài 34 gặp với tài khoản bị vô hiệu hóa, cũng là một AuthenticationException, được cùng handler đó trả lời.
Security: một filter chain, JWT và method security
Filter chain
Bài 33 chia ứng dụng thành một chain cho API và một chain form login, còn bài 39 thêm chain thứ ba cho Actuator. Dự án tổng kết không phục vụ trang HTML nào, nên một chain duy nhất bao mọi request, và không request nào có thể lọt ra ngoài mọi chain, lỗ hổng mà bài 39 đã tìm thấy với /actuator:
package com.example.orders.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/auth/register", "/api/auth/login").permitAll()
.requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
.requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll()
.requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.POST, "/api/orders/*/ship").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
.authenticationEntryPoint(problemHandler))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
}- Public: đọc product, đăng ký và đăng nhập (rule của bài 36),
GET /actuator/healthcho healthcheck của Compose, và các path tài liệu. ADMIN: tạo product và ship order, quyết định theo URL vì rule chỉ phụ thuộc vào path.- Đã đăng nhập: mọi thứ còn lại, gồm các endpoint order khác, nơi rule chi tiết hơn là method security bên dưới.
- Phần còn lại là resource server của bài 35 với
ProblemDetailSecurityHandlerlàm cả hai entry point, tắt CSRF cho API dùng bearer token và không có session.
Theo mặc định Actuator chỉ expose health qua HTTP (bài 39); /actuator và /actuator/info không kèm token nhận 401 của chain.
Vì sao chain và các bean token nằm ở hai class riêng
Bài 38 import SecurityConfig vào một @WebMvcTest và gặp StackOverflowError ngay khi một request mang bearer token thật nhưng không hợp lệ, vì class đó còn khai báo cả bean AuthenticationManager. Ở đây chain có class riêng, còn các bean mà authentication và việc ký token cần nằm ở class khác:
package com.example.orders.common;
import java.security.interfaces.RSAPrivateKey;
import java.security.interfaces.RSAPublicKey;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtIssuerValidator;
import org.springframework.security.oauth2.jwt.JwtTimestampValidator;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
@Configuration
public class AuthConfig {
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) {
return configuration.getAuthenticationManager();
}
@Bean
JwtEncoder jwtEncoder(
@Value("${spring.security.oauth2.resourceserver.jwt.public-key-location}") RSAPublicKey publicKey,
@Value("${app.jwt.private-key-location}") RSAPrivateKey privateKey) {
return NimbusJwtEncoder.withKeyPair(publicKey, privateKey).build();
}
@Bean
JwtTimestampValidator jwtTimestampValidator() {
JwtTimestampValidator validator = new JwtTimestampValidator(Duration.ZERO);
validator.setAllowEmptyExpiryClaim(false);
return validator;
}
@Bean
JwtIssuerValidator jwtIssuerValidator(@Value("${app.jwt.issuer}") String issuer) {
return new JwtIssuerValidator(issuer);
}
}Web slice chỉ import SecurityConfig và ProblemDetailSecurityHandler. Để xác nhận việc tách class là điều quyết định, một bản sao của test class, SingleSecurityConfigControllerTest, thêm AuthConfig vào @Import, và test gửi Authorization: Bearer abc.def.ghi của nó fail y như ở bài 38:
SingleSecurityConfigControllerTest > placeWithAnInvalidTokenIs401() FAILED
java.lang.StackOverflowError at ReflectiveOperationException.java:90java.lang.StackOverflowError
at java.base/java.lang.ReflectiveOperationException.<init>(ReflectiveOperationException.java:90)
at java.base/java.lang.reflect.InvocationTargetException.<init>(InvocationTargetException.java:68)
at java.base/java.lang.reflect.Method.invoke(Method.java:580)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:215)
at jdk.proxy3/jdk.proxy3.$Proxy220.authenticate(Unknown Source)Không có AuthConfig, slice không có bean AuthenticationManager nào để lazy proxy gọi vào, và cùng request đó trả đúng 401 mà test mong đợi. Method security cũng có class riêng vì cùng lý do, để slice không bọc service đã mock trong các bước kiểm tra authorization:
package com.example.orders.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}Entry point và access denied handler kết hợp phần ghi ProblemDetail của bài 33 với việc ủy quyền cho BearerTokenAuthenticationEntryPoint của bài 35; bài 35 chỉ trình bày thay đổi đó dưới dạng diff, nên đây là toàn bộ class:
package com.example.orders.common;
import java.io.IOException;
import java.net.URI;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationEntryPoint;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
private final JsonMapper jsonMapper;
private final BearerTokenAuthenticationEntryPoint bearerEntryPoint = new BearerTokenAuthenticationEntryPoint();
public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
this.bearerEntryPoint.setRealmName("orders");
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
bearerEntryPoint.commence(request, response, ex);
write(request, response, HttpStatus.valueOf(response.getStatus()),
"Valid credentials are required to access this resource.");
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException {
write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
}
private void write(HttpServletRequest request, HttpServletResponse response,
HttpStatus status, String detail) throws IOException {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
jsonMapper.writeValue(response.getOutputStream(), problem);
}
}User, đăng ký, đăng nhập và admin đầu tiên
Package user gần như giữ nguyên từ bài 34 và 35. Các class sau giống hệt phiên bản cuối ở đó, chỉ đổi package thành com.example.orders.user:
| Class | Giống ở |
|---|---|
Role | bài 34: USER, ADMIN |
AppUserRepository | bài 34: findByUsername, existsByUsername, existsByEmail |
JpaUserDetailsService | bài 34, kể cả updatePassword |
RegisterRequest | bài 34, với check 72 byte bằng @AssertTrue |
LoginRequest, UserResponse | bài 34 |
UserService | bài 34: register và findByUsername |
TokenResponse | bài 35 |
AppUser có thêm một constructor nhận role, dành cho administrator:
package com.example.orders.user;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "users")
public class AppUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 50, unique = true)
private String username;
@Column(nullable = false, length = 254, unique = true)
private String email;
@Column(nullable = false, length = 100)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private Role role = Role.USER;
private boolean enabled = true;
protected AppUser() {
}
public AppUser(String username, String email, String passwordHash) {
this(username, email, passwordHash, Role.USER);
}
public AppUser(String username, String email, String passwordHash, Role role) {
this.username = username;
this.email = email;
this.passwordHash = passwordHash;
this.role = role;
}
public Long getId() { return id; }
public String getUsername() { return username; }
public String getEmail() { return email; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public Role getRole() { return role; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof AppUser other)) return false;
return id != null && id.equals(other.getId());
}
@Override
public int hashCode() {
return AppUser.class.hashCode();
}
}UserAlreadyExistsException giờ kế thừa ConflictException:
package com.example.orders.user;
import com.example.orders.common.ConflictException;
public class UserAlreadyExistsException extends ConflictException {
public UserAlreadyExistsException(String message) {
super(message);
}
}TokenService đọc issuer từ app.jwt.issuer:
package com.example.orders.user;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
import org.springframework.stereotype.Service;
@Service
public class TokenService {
private static final Duration LIFETIME = Duration.ofMinutes(15);
private final JwtEncoder jwtEncoder;
private final String issuer;
public TokenService(JwtEncoder jwtEncoder, @Value("${app.jwt.issuer}") String issuer) {
this.jwtEncoder = jwtEncoder;
this.issuer = issuer;
}
public TokenResponse issue(Authentication authentication) {
Instant now = Instant.now();
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.filter(authority -> authority.startsWith("ROLE_"))
.map(authority -> authority.substring("ROLE_".length()))
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer(issuer)
.subject(authentication.getName())
.issuedAt(now)
.expiresAt(now.plus(LIFETIME))
.claim("roles", roles)
.build();
String token = jwtEncoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
return new TokenResponse(token, "Bearer", LIFETIME.toSeconds());
}
}AuthController là của bài 35 cộng thêm annotation của springdoc: một tag, và bearer requirement trên me:
package com.example.orders.user;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@Tag(name = "Authentication")
public class AuthController {
private final UserService userService;
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(UserService userService, AuthenticationManager authenticationManager,
TokenService tokenService) {
this.userService = userService;
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
@PostMapping("/register")
public ResponseEntity<UserResponse> register(@Valid @RequestBody RegisterRequest request) {
AppUser user = userService.register(request);
return ResponseEntity.status(HttpStatus.CREATED).body(UserResponse.from(user));
}
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(request.username(), request.password()));
return tokenService.issue(authentication);
}
@GetMapping("/me")
@SecurityRequirement(name = "bearer-jwt")
public UserResponse me(@AuthenticationPrincipal Jwt jwt) {
return UserResponse.from(userService.findByUsername(jwt.getSubject()));
}
}Tạo administrator đầu tiên
Đăng ký chỉ tạo tài khoản USER, nên ADMIN đầu tiên phải đến từ chỗ khác. Một migration Flyway có thể insert tài khoản đó kèm một BCrypt hash, nhưng khi đó hash ấy, tức là một password đã biết, sẽ có mặt trong mọi database từng được dựng từ repository, cả development lẫn production. Một runner chỉ tạo tài khoản khi app.admin.password được đặt, và chỉ khi username chưa tồn tại, giữ credential ngoài repository và cho mỗi môi trường tự chọn password:
package com.example.orders.user;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
@Component
class AdminAccountInitializer implements ApplicationRunner {
private static final Logger log = LoggerFactory.getLogger(AdminAccountInitializer.class);
private final AppUserRepository users;
private final PasswordEncoder passwordEncoder;
private final String username;
private final String email;
private final String password;
AdminAccountInitializer(AppUserRepository users, PasswordEncoder passwordEncoder,
@Value("${app.admin.username}") String username,
@Value("${app.admin.email}") String email,
@Value("${app.admin.password:}") String password) {
this.users = users;
this.passwordEncoder = passwordEncoder;
this.username = username;
this.email = email;
this.password = password;
}
@Override
public void run(ApplicationArguments args) {
if (password.isBlank()) {
log.info("app.admin.password is not set, no admin account created");
return;
}
if (users.existsByUsername(username)) {
log.info("Admin account '{}' already exists", username);
return;
}
users.save(new AppUser(username, email, passwordEncoder.encode(password), Role.ADMIN));
log.info("Created admin account '{}'", username);
}
}Khi không có property này, như trong test, runner log app.admin.password is not set, no admin account created. Lần khởi động đầu của stack Compose log Created admin account 'admin', còn khi restart container ứng dụng thì log Admin account 'admin' already exists. Đổi password sau đó không phải việc của runner.
Product
Product là entity của bài 37 cùng rule về stock, giờ có thêm cột audit, độ dài khớp với V2, và thao tác ngược lại cho việc hủy order:
package com.example.orders.product;
import java.math.BigDecimal;
import com.example.orders.common.AuditableEntity;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, length = 40, unique = true)
private String sku;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
protected Product() {
}
public Product(String name, String sku, BigDecimal price, int stock) {
this.name = name;
this.sku = sku;
this.price = price;
this.stock = stock;
}
public void decreaseStock(int quantity) {
requirePositive(quantity);
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
public void increaseStock(int quantity) {
requirePositive(quantity);
stock += quantity;
}
private static void requirePositive(int quantity) {
if (quantity <= 0) {
throw new IllegalArgumentException("Quantity must be positive, was " + quantity);
}
}
public Long getId() {
return id;
}
public String getName() {
return name;
}
public String getSku() {
return sku;
}
public BigDecimal getPrice() {
return price;
}
public int getStock() {
return stock;
}
}package com.example.orders.product;
import java.util.Optional;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
public interface ProductRepository extends JpaRepository<Product, Long> {
boolean existsBySku(String sku);
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findForUpdateById(Long id);
}findForUpdateById là một derived query của bài 27 kèm lock mode; phần về order service giải thích vì sao việc đặt order cần nó.
package com.example.orders.product;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) {
this.products = products;
}
public Page<Product> findPage(Pageable pageable) {
return products.findAll(pageable);
}
public Product findById(Long id) {
return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product create(Product product) {
if (products.existsBySku(product.getSku())) {
throw new DuplicateSkuException(product.getSku());
}
return products.save(product);
}
}package com.example.orders.product;
import java.math.BigDecimal;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.PositiveOrZero;
import jakarta.validation.constraints.Size;
public record CreateProductRequest(
@NotBlank @Size(max = 120) String name,
@NotBlank @Size(max = 40) String sku,
@NotNull @Positive @Digits(integer = 8, fraction = 2) BigDecimal price,
@NotNull @PositiveOrZero Integer stock) {
Product toProduct() {
return new Product(name, sku, price, stock);
}
}package com.example.orders.product;
import java.math.BigDecimal;
import java.time.Instant;
public record ProductResponse(Long id, String name, String sku, BigDecimal price, int stock,
Instant createdAt, Instant updatedAt) {
static ProductResponse from(Product product) {
return new ProductResponse(product.getId(), product.getName(), product.getSku(), product.getPrice(),
product.getStock(), product.getCreatedAt(), product.getUpdatedAt());
}
}package com.example.orders.product;
import java.net.URI;
import com.example.orders.common.PageResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
@Tag(name = "Products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping
public PageResponse<ProductResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable) {
return PageResponse.from(service.findPage(pageable).map(ProductResponse::from));
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return ProductResponse.from(service.findById(id));
}
@PostMapping
@SecurityRequirement(name = "bearer-jwt")
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product product = service.create(request.toProduct());
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(product.getId())
.toUri();
return ResponseEntity.created(location).body(ProductResponse.from(product));
}
}@ParameterObject dành cho springdoc, và phần tài liệu API cho thấy nó sửa được gì. Ba exception giữ nguyên message mà bài 21 và 37 đã dùng:
package com.example.orders.product;
import com.example.orders.common.NotFoundException;
public class ProductNotFoundException extends NotFoundException {
public ProductNotFoundException(Long id) {
super("Product " + id + " not found");
}
}package com.example.orders.product;
import com.example.orders.common.ConflictException;
public class DuplicateSkuException extends ConflictException {
public DuplicateSkuException(String sku) {
super("SKU " + sku + " already exists");
}
}package com.example.orders.product;
import com.example.orders.common.ConflictException;
public class InsufficientStockException extends ConflictException {
public InsufficientStockException(String sku, int available, int requested) {
super("Only " + available + " of " + sku + " in stock, " + requested + " requested");
}
}Order: entity, repository và ownership
Một order có customer, status và các line. Việc chuyển status nằm trong entity, giống cách bài 37 đưa rule về stock vào Product:
package com.example.orders.order;
public enum OrderStatus {
PLACED, SHIPPED, CANCELLED
}package com.example.orders.order;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import com.example.orders.common.AuditableEntity;
import com.example.orders.user.AppUser;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany;
import jakarta.persistence.Table;
@Entity
@Table(name = "orders")
public class Order extends AuditableEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "customer_id", nullable = false)
private AppUser customer;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private OrderStatus status = OrderStatus.PLACED;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
protected Order() {
}
public Order(AppUser customer) {
this.customer = customer;
}
public void addLine(OrderLine line) {
lines.add(line);
line.setOrder(this);
}
public void ship() {
if (status != OrderStatus.PLACED) {
throw new OrderStatusException("Order " + id + " is " + status + " and cannot be shipped");
}
status = OrderStatus.SHIPPED;
}
public void cancel() {
if (status != OrderStatus.PLACED) {
throw new OrderStatusException("Order " + id + " is " + status + " and cannot be cancelled");
}
status = OrderStatus.CANCELLED;
}
public BigDecimal total() {
return lines.stream()
.map(OrderLine::lineTotal)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
public Long getId() {
return id;
}
public AppUser getCustomer() {
return customer;
}
public OrderStatus getStatus() {
return status;
}
public List<OrderLine> getLines() {
return lines;
}
}package com.example.orders.order;
import java.math.BigDecimal;
import com.example.orders.product.Product;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
@Entity
@Table(name = "order_lines")
public class OrderLine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id", nullable = false)
private Order order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id", nullable = false)
private Product product;
@Column(nullable = false)
private int quantity;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal unitPrice;
protected OrderLine() {
}
public OrderLine(Product product, int quantity) {
this.product = product;
this.quantity = quantity;
this.unitPrice = product.getPrice();
}
public BigDecimal lineTotal() {
return unitPrice.multiply(BigDecimal.valueOf(quantity));
}
void setOrder(Order order) {
this.order = order;
}
public Long getId() {
return id;
}
public Product getProduct() {
return product;
}
public int getQuantity() {
return quantity;
}
public BigDecimal getUnitPrice() {
return unitPrice;
}
}OrderLine là mapping cuối cùng của bài 28: cả hai @ManyToOne đều lazy và NOT NULL, đơn giá được chép từ product lúc tạo line. Record của request và response cũng là của bài 28, response có thêm status và timestamp audit:
package com.example.orders.order;
public record OrderItem(Long productId, int quantity) {
}package com.example.orders.order;
import java.util.List;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
public record PlaceOrderRequest(@NotEmpty List<@Valid Line> lines) {
public record Line(@NotNull Long productId, @NotNull @Positive Integer quantity) {
}
}package com.example.orders.order;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
public record OrderResponse(Long id, Long customerId, OrderStatus status, List<OrderLineResponse> lines,
BigDecimal total, Instant createdAt, Instant updatedAt) {
public record OrderLineResponse(Long productId, int quantity, BigDecimal unitPrice, BigDecimal lineTotal) {
static OrderLineResponse from(OrderLine line) {
return new OrderLineResponse(line.getProduct().getId(), line.getQuantity(),
line.getUnitPrice(), line.lineTotal());
}
}
static OrderResponse from(Order order) {
return new OrderResponse(order.getId(), order.getCustomer().getId(), order.getStatus(),
order.getLines().stream().map(OrderLineResponse::from).toList(), order.total(),
order.getCreatedAt(), order.getUpdatedAt());
}
}customerId đọc id của một reference AppUser lazy, điều mà bài 28 đã cho thấy không tốn query nào; response không bao giờ chạm vào phần còn lại của customer.
package com.example.orders.order;
import java.util.Optional;
import jakarta.persistence.LockModeType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
public interface OrderRepository extends JpaRepository<Order, Long> {
@EntityGraph(attributePaths = "lines")
Optional<Order> findWithLinesById(Long id);
@EntityGraph(attributePaths = "lines")
Page<Order> findByCustomerUsername(String username, Pageable pageable);
@Override
@EntityGraph(attributePaths = "lines")
Page<Order> findAll(Pageable pageable);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@EntityGraph(attributePaths = "lines")
Optional<Order> findForUpdateById(Long id);
@Query("select o.customer.username from Order o where o.id = :id")
Optional<String> findCustomerUsernameById(Long id);
}findWithLinesByIdcùngfindByCustomerUsernamevàfindAllcó phân trang fetch các line bằng entity graph, như bài 28 và 29.findForUpdateByIdlock row của order chocancelvàship, kèm các line.findCustomerUsernameByIdtrả lời đúng một câu hỏi cho rule ownership: order này của ai.
package com.example.orders.order;
import org.springframework.stereotype.Component;
@Component
public class OrderAccess {
private final OrderRepository orders;
public OrderAccess(OrderRepository orders) {
this.orders = orders;
}
public boolean isOwner(Long orderId, String username) {
return orders.findCustomerUsernameById(orderId)
.map(owner -> owner.equals(username))
.orElse(true); // an unknown id passes, so the service itself answers 404
}
}package com.example.orders.order;
import com.example.orders.common.NotFoundException;
public class OrderNotFoundException extends NotFoundException {
public OrderNotFoundException(Long id) {
super("Order " + id + " not found");
}
}package com.example.orders.order;
import com.example.orders.common.ConflictException;
public class OrderStatusException extends ConflictException {
public OrderStatusException(String message) {
super(message);
}
}Business logic: OrderService.place, cancel và ship
package com.example.orders.order;
import java.util.Comparator;
import java.util.List;
import com.example.orders.product.Product;
import com.example.orders.product.ProductNotFoundException;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class OrderService {
private final OrderRepository orders;
private final ProductRepository products;
private final AppUserRepository users;
public OrderService(OrderRepository orders, ProductRepository products, AppUserRepository users) {
this.orders = orders;
this.products = products;
this.users = users;
}
@Transactional
public Order place(String username, List<OrderItem> items) {
AppUser customer = users.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("No user named " + username));
Order order = new Order(customer);
List<OrderItem> inProductOrder = items.stream()
.sorted(Comparator.comparing(OrderItem::productId))
.toList();
for (OrderItem item : inProductOrder) {
Product product = products.findForUpdateById(item.productId())
.orElseThrow(() -> new ProductNotFoundException(item.productId()));
product.decreaseStock(item.quantity());
order.addLine(new OrderLine(product, item.quantity()));
}
return orders.save(order);
}
@PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
public Order findById(Long id) {
return orders.findWithLinesById(id).orElseThrow(() -> new OrderNotFoundException(id));
}
public Page<Order> findByCustomer(String username, Pageable pageable) {
return orders.findByCustomerUsername(username, pageable);
}
@PreAuthorize("hasRole('ADMIN')")
public Page<Order> findAll(Pageable pageable) {
return orders.findAll(pageable);
}
@Transactional
@PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
public Order cancel(Long id) {
Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
order.cancel();
List<OrderLine> inProductOrder = order.getLines().stream()
.sorted(Comparator.comparing(line -> line.getProduct().getId()))
.toList();
for (OrderLine line : inProductOrder) {
Product product = products.findForUpdateById(line.getProduct().getId()).orElseThrow();
product.increaseStock(line.getQuantity());
}
return order;
}
@Transactional
public Order ship(Long id) {
Order order = orders.findForUpdateById(id).orElseThrow(() -> new OrderNotFoundException(id));
order.ship();
return order;
}
}placechạy trong một transaction (bài 30). Nó load customer mà token chỉ ra, rồi với từng item thì lock row của product, đểProduct.decreaseStockáp rule và thêm một line với giá hiện tại. Nếu một line bất kỳ thiếu hàng,InsufficientStockExceptionrollback toàn bộ transaction, kể cả phần stock đã trừ cho các line trước, và advice trả 409. Chỉ khi mọi line đều qua thì order mới được lưu, và cascade persist luôn các line (bài 28).cancellock order, đểOrder.canceltừ chối mọi order không cònPLACED, rồi cộng lại số lượng của từng line vào product, cũng dưới row lock.shiplock order và đểOrder.shipáp cùng rule về trạng thái.- Các method đọc thừa hưởng
readOnly = truetừ class (bài 30).
Mọi method đều trả entity, và OrderController map chúng sau khi transaction đã commit. Điều đó an toàn ở đây vì mỗi method trả về một order mà các line đã được fetch hoặc chạm tới bên trong transaction, và vì listener audit đặt updatedAt lên object đang được quản lý trong lúc flush, nên updatedAt mà một lần hủy trả về đã là giá trị mới; hiện tượng "chậm một lần flush" của bài 32 chỉ xảy ra khi map bên trong transaction.

Vì sao đặt order phải lock row của product
reserveStock ở bài 30 đọc product, giảm stock trong bộ nhớ và để dirty checking ghi xuống. Trong một transaction thì việc đó là trọn vẹn hoặc không gì cả, nhưng hai transaction có thể đọc cùng một giá trị stock cùng lúc, và mức isolation mặc định của PostgreSQL, READ COMMITTED, cho phép cả hai cùng ghi. Để đo, @Lock được bỏ khỏi findForUpdateById cho thí nghiệm này, và hai mươi request, mỗi request đặt một USB-C hub, được gửi song song tới một product còn 5 cái trong stock, ba lần, với load average một phút ở mức 3.7:
seq 1 20 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":3,"quantity":1}]}' http://localhost:8142/api/orders | sort | uniq -c| Lần chạy không lock | 201 | 409 | stock sau đó |
|---|---|---|---|
| 1 | 20 | 0 | 2 |
| 2 | 18 | 2 | 0 |
| 3 | 20 | 0 | 0 |
Hai mươi order cho năm cái hub, và ở lần chạy đầu còn dư cả stock 2: mỗi lost update là một cái hub đã bán mà không bị trừ. Khi đặt @Lock(LockModeType.PESSIMISTIC_WRITE) trở lại, cùng loạt request đó trên stack Compose bên dưới, cho một cái đèn bàn (product 4, LMP-01) còn 5 trong stock:
5 201
15 409 sku | stock
--------+-------
LMP-01 | 0
(1 row)
orders_for_lamp
-----------------
5
(1 row)Năm order, năm line, stock 0. SQL cho thấy vì sao. Cùng JAR đó chạy với PostgreSQL 18 và --logging.level.org.hibernate.SQL=debug, cho order của alice gồm một con chuột (product 2) và hai bàn phím (product 1), danh sách cột của các câu SELECT được rút gọn:
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
insert into orders (created_at,customer_id,status,updated_at) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
insert into order_lines (order_id,product_id,quantity,unit_price) values (?,?,?,?)
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?- Hibernate 7.4.5 ghi
PESSIMISTIC_WRITEthànhfor no key update of p1_0trên PostgreSQL: một row lock chỉ trên product được chọn. - Một request đồng thời phải chờ ở câu
select … for no key updatecủa chính nó cho tới khi transaction đầu kết thúc, rồi đọc giá trị stock mà transaction đó đã commit. Đó là lý do loạt request dừng đúng ở năm. - Câu UPDATE đầu tiên đến trước lock thứ hai. Hibernate flush stock đã thay đổi của bàn phím trước khi chạy một query khác trên
products; UPDATE của con chuột chờ tới lúc commit. - Bàn phím đi trước, dù request liệt kê con chuột trước.
placesắp các item theo id của product, nên hai order dùng chung product luôn lock chúng theo cùng thứ tự và không thể deadlock lẫn nhau.
Order xin năm con chuột khi chỉ còn hai dừng lại sau lock thứ hai:
select au1_0.id,au1_0.email,au1_0.enabled,au1_0.password_hash,au1_0.role,au1_0.username from users au1_0 where au1_0.username=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0
update products set name=?,price=?,sku=?,stock=?,updated_at=? where id=?
select p1_0.id,…,p1_0.updated_at from products p1_0 where p1_0.id=? for no key update of p1_0Câu UPDATE của bàn phím đã được gửi rồi bị rollback cùng transaction, và không câu INSERT nào được gửi; lượt curl bên dưới đọc lại stock không đổi.
cancel có thêm một chi tiết. Lock và entity graph nằm trên cùng một method của repository, và Hibernate không đặt mệnh đề lock lên fetch join: nó chạy join không có lock rồi lock row của order bằng một câu thứ hai:
select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from orders o1_0 left join order_lines l1_0 on o1_0.id=l1_0.order_id where o1_0.id=?
select tbl.id,tbl.created_at,tbl.customer_id,tbl.status,tbl.updated_at from orders tbl where tbl.id in (?) for no key update of tblMười request hủy song song cho một order gồm hai bàn phím trả về một 200 và chín 409, và stock bàn phím từ 8 quay về đúng 10: những request phải chờ lock thấy order đã CANCELLED khi tới lượt chúng.
Rule ownership nằm ở đâu
"User chỉ xem và hủy được order của chính mình" có thể được áp ở ba chỗ. Một URL rule thấy /api/orders/7 nhưng không biết order 7 của ai. Một query của repository giới hạn theo người gọi, findByIdAndCustomerUsername, sẽ biến order của người khác thành 404 không phân biệt được với order không tồn tại, trong khi bài 36 trả 403. Vì vậy rule là method security trên service, như bảng của bài 36 khuyên dùng cho rule dựa trên dữ liệu, viết một lần trong OrderAccess và được cả findById lẫn cancel dùng; @PostAuthorize dùng được cho thao tác đọc, nhưng một lần hủy phải bị từ chối trước khi nó ghi. orElse(true) của nó cho một id không tồn tại đi tiếp vào method, nên order không tồn tại vẫn là 404 với mọi người. Endpoint danh sách là nơi duy nhất mà query giới hạn theo người gọi là đúng hoàn toàn, vì một danh sách không có trường hợp "của người khác": user dùng findByCustomerUsername, admin dùng findAll.
package com.example.orders.order;
import java.net.URI;
import java.util.List;
import com.example.orders.common.PageResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/orders")
@Tag(name = "Orders")
@SecurityRequirement(name = "bearer-jwt")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<OrderResponse> place(@Valid @RequestBody PlaceOrderRequest request,
Authentication authentication) {
List<OrderItem> items = request.lines().stream()
.map(line -> new OrderItem(line.productId(), line.quantity()))
.toList();
Order order = service.place(authentication.getName(), items);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(order.getId())
.toUri();
return ResponseEntity.created(location).body(OrderResponse.from(order));
}
@GetMapping
public PageResponse<OrderResponse> findAll(@ParameterObject @PageableDefault(size = 20, sort = "id") Pageable pageable,
Authentication authentication) {
Page<Order> page = isAdmin(authentication)
? service.findAll(pageable)
: service.findByCustomer(authentication.getName(), pageable);
return PageResponse.from(page.map(OrderResponse::from));
}
@GetMapping("/{id}")
public OrderResponse findById(@PathVariable Long id) {
return OrderResponse.from(service.findById(id));
}
@PostMapping("/{id}/cancel")
public OrderResponse cancel(@PathVariable Long id) {
return OrderResponse.from(service.cancel(id));
}
@PostMapping("/{id}/ship")
public OrderResponse ship(@PathVariable Long id) {
return OrderResponse.from(service.ship(id));
}
private static boolean isAdmin(Authentication authentication) {
return authentication.getAuthorities().stream()
.anyMatch(authority -> "ROLE_ADMIN".equals(authority.getAuthority()));
}
}Controller nhận Authentication thay vì @AuthenticationPrincipal Jwt như bài 35, vì endpoint danh sách cần cả authorities lẫn tên.
Tài liệu API với springdoc và bearer scheme
FAQ của bài 22 để dành nút Authorize cho bearer token tới chương security. Nút đó cần một security scheme trong document:
package com.example.orders.common;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
OpenAPI ordersOpenApi() {
return new OpenAPI()
.info(new Info()
.title("Orders API")
.version("1.0.0")
.description("Products, orders and JWT authentication for the Spring Boot Basics capstone."))
.components(new Components()
.addSecuritySchemes("bearer-jwt", new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}@SecurityRequirement(name = "bearer-jwt") trên OrderController, trên ProductController.create và trên AuthController.me gắn scheme đó vào các operation cần token. Document tại /v3/api-docs, rút gọn còn scheme và operation đặt order:
curl -s http://localhost:8142/v3/api-docs | jq '{securitySchemes: .components.securitySchemes, placeOrder: .paths["/api/orders"].post}'{
"securitySchemes": {
"bearer-jwt": {
"type": "http",
"scheme": "bearer",
"bearerFormat": "JWT"
}
},
"placeOrder": {
"tags": [
"Orders"
],
"operationId": "place",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PlaceOrderRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "OK",
"content": {
"*/*": {
"schema": {
"$ref": "#/components/schemas/OrderResponse"
}
}
}
}
},
"security": [
{
"bearer-jwt": []
}
]
}
}Tham số Authentication của place không có trong document, @AuthenticationPrincipal Jwt của me cũng vậy: springdoc đã bỏ cả hai. 200 là giới hạn của bài 22, vì springdoc không thấy được status chọn trong thân method; các annotation @ApiResponse của bài 22 mô tả đúng 201 và các lỗi.
Pageable cần @ParameterObject
Khi chưa có @ParameterObject, cả hai endpoint danh sách được mô tả bằng một query parameter bắt buộc:
{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}Swagger UI sẽ dựng form cho một object pageable mà controller không hề đọc. Có annotation, cùng operation đó liệt kê đúng những gì resolver của Spring Data nhận, kể cả giá trị mặc định từ @PageableDefault:
curl -s http://localhost:8142/v3/api-docs | jq -c '.paths["/api/orders"].get.parameters'[{"name":"page","in":"query","description":"Zero-based page index (0..N)","required":false,"schema":{"type":"integer","default":0,"minimum":0}},{"name":"size","in":"query","description":"The size of the page to be returned","required":false,"schema":{"type":"integer","default":20,"minimum":1}},{"name":"sort","in":"query","description":"Sorting criteria in the format: property,(asc|desc). Default sort order is ascending. Multiple sort criteria are supported.","required":false,"schema":{"type":"array","default":["id,ASC"],"items":{"type":"string"}}}]Các path mà Swagger UI cần đi qua security
Chain cho phép /v3/api-docs/**, /swagger-ui/** và /swagger-ui.html. Chrome headless mở http://localhost:8142/swagger-ui.html không kèm token đã đi theo 302 tới /swagger-ui/index.html và nhận 200 cho mọi request mà trang gửi: index.html, swagger-ui.css, index.css, swagger-ui-standalone-preset.js, swagger-ui-bundle.js, swagger-initializer.js, favicon-32x32.png, /v3/api-docs/swagger-config và /v3/api-docs. Có một URL tài liệu không được bao: /v3/api-docs.yaml không nằm dưới /v3/api-docs/, và nó nhận 401 của chain. Thêm nó vào rule nếu bạn công bố dạng YAML.
Luồng dùng Swagger UI với token, như đã chạy trên stack Compose:
- Trang liệt kê các tag
Products,OrdersvàAuthentication; biểu tượng ổ khóa đánh dấuPOST /api/products, mọi operation của order vàGET /api/auth/me. - Authorize mở một hộp thoại có tiêu đề
bearer-jwt (http, Bearer)với một ô Value. DánaccessTokentừPOST /api/auth/login, không kèm chữBearer, rồi bấm Authorize; hộp thoại chuyển sangAuthorizedvàValue: ******. Bấm Close. - Mở
POST /api/orders, bấm Try it out, thay body mẫu bằng{"lines":[{"productId":3,"quantity":1}]}rồi bấm Execute.
Khi đó ô Curl cho thấy header mà Swagger UI đã thêm, token được rút gọn ở đây:
curl -X 'POST' \
'http://localhost:8142/api/orders' \
-H 'accept: */*' \
-H 'Authorization: Bearer eyJraWQiOiJ1cWU1bnBBb1ZC…' \
-H 'Content-Type: application/json' \
-d '{"lines":[{"productId":3,"quantity":1}]}'Server response báo 201, gắn nhãn Undocumented vì document chỉ biết 200, và trong response header có location: http://localhost:8142/api/orders/8. Try it out đặt order thật; profile production của bài 22 tắt phần tài liệu.
Test
Unit test cho các rule của order
Service được tạo bằng new và ba repository là mock của Mockito, theo phong cách của bài 37. Product và order là object thật, nên decreaseStock, increaseStock, cancel và ship chạy thật:
package com.example.orders.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.assertj.core.api.Assertions.tuple;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.never;
import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUser;
import com.example.orders.user.AppUserRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils;
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock
private OrderRepository orders;
@Mock
private ProductRepository products;
@Mock
private AppUserRepository users;
private OrderService service;
private AppUser alice;
private Product keyboard;
private Product mouse;
@BeforeEach
void setUp() {
service = new OrderService(orders, products, users);
alice = new AppUser("alice", "alice@example.com", "hash");
keyboard = withId(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10), 1L);
mouse = withId(new Product("Wireless mouse", "MS-01", new BigDecimal("24.50"), 1), 2L);
}
@Test
void placeTakesStockForEveryLineAndSavesTheOrder() {
given(users.findByUsername("alice")).willReturn(Optional.of(alice));
given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
given(orders.save(any(Order.class))).willAnswer(invocation -> invocation.getArgument(0));
Order order = service.place("alice", List.of(new OrderItem(2L, 1), new OrderItem(1L, 2)));
assertThat(order.getStatus()).isEqualTo(OrderStatus.PLACED);
assertThat(order.getLines())
.extracting(line -> line.getProduct().getSku(), OrderLine::getQuantity)
.containsExactly(tuple("KB-01", 2), tuple("MS-01", 1));
assertThat(order.total()).isEqualByComparingTo("204.30");
assertThat(keyboard.getStock()).isEqualTo(8);
assertThat(mouse.getStock()).isZero();
}
@Test
void placeSavesNothingWhenALineHasTooLittleStock() {
given(users.findByUsername("alice")).willReturn(Optional.of(alice));
given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
given(products.findForUpdateById(2L)).willReturn(Optional.of(mouse));
assertThatThrownBy(() -> service.place("alice", List.of(new OrderItem(1L, 2), new OrderItem(2L, 3))))
.isInstanceOf(InsufficientStockException.class)
.hasMessage("Only 1 of MS-01 in stock, 3 requested");
then(orders).should(never()).save(any());
}
@Test
void cancelPutsTheStockBack() {
Order order = placedOrder(7L, keyboard, 2);
given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
given(products.findForUpdateById(1L)).willReturn(Optional.of(keyboard));
service.cancel(7L);
assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELLED);
assertThat(keyboard.getStock()).isEqualTo(12);
}
@Test
void cancelRefusesAShippedOrderAndKeepsTheStock() {
Order order = placedOrder(7L, keyboard, 2);
order.ship();
given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
assertThatThrownBy(() -> service.cancel(7L))
.isInstanceOf(OrderStatusException.class)
.hasMessage("Order 7 is SHIPPED and cannot be cancelled");
assertThat(keyboard.getStock()).isEqualTo(10);
then(products).should(never()).findForUpdateById(any());
}
@Test
void shipRefusesACancelledOrder() {
Order order = placedOrder(7L, keyboard, 2);
order.cancel();
given(orders.findForUpdateById(7L)).willReturn(Optional.of(order));
assertThatThrownBy(() -> service.ship(7L))
.isInstanceOf(OrderStatusException.class)
.hasMessage("Order 7 is CANCELLED and cannot be shipped");
}
private Order placedOrder(Long id, Product product, int quantity) {
Order order = new Order(alice);
order.addLine(new OrderLine(product, quantity));
return withId(order, id);
}
private static <T> T withId(T entity, Long id) {
ReflectionTestUtils.setField(entity, "id", id);
return entity;
}
}placeTakesStockForEveryLineAndSavesTheOrder còn chốt luôn thứ tự lock: các line trả về có bàn phím trước, vì place đã sắp các item. Như bài 37 đã lưu ý, unit test không chứng minh được rollback; các bước kiểm tra stock trong lượt chạy Docker làm việc đó.
@WebMvcTest cho OrderController kèm security
package com.example.orders.order;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.given;
import static org.mockito.BDDMockito.then;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import java.math.BigDecimal;
import java.util.List;
import com.example.orders.common.ProblemDetailSecurityHandler;
import com.example.orders.common.SecurityConfig;
import com.example.orders.product.InsufficientStockException;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authorization.AuthorizationDeniedException;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.JwtRequestPostProcessor;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.assertj.MockMvcTester;
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})
class OrderControllerTest {
private static final String TWO_KEYBOARDS = """
{"lines":[{"productId":1,"quantity":2}]}
""";
@Autowired
MockMvcTester mvc;
@MockitoBean
OrderService orderService;
@Test
void placeWithoutATokenIs401() {
assertThat(mvc.post().uri("/api/orders")
.contentType(MediaType.APPLICATION_JSON)
.content(TWO_KEYBOARDS))
.hasStatus(HttpStatus.UNAUTHORIZED)
.hasHeader(HttpHeaders.WWW_AUTHENTICATE,
"Bearer realm=\"orders\", resource_metadata=\"http://localhost/.well-known/oauth-protected-resource\"")
.bodyJson()
.extractingPath("$.detail").asString().isEqualTo("Valid credentials are required to access this resource.");
}
@Test
void placeWithAnInvalidTokenIs401() {
assertThat(mvc.post().uri("/api/orders")
.header(HttpHeaders.AUTHORIZATION, "Bearer abc.def.ghi")
.contentType(MediaType.APPLICATION_JSON)
.content(TWO_KEYBOARDS))
.hasStatus(HttpStatus.UNAUTHORIZED);
}
@Test
void placeAnswers201WithTheLocationOfTheOrder() {
given(orderService.place(eq("alice"), anyList())).willReturn(order(42L));
assertThat(mvc.post().uri("/api/orders")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content(TWO_KEYBOARDS))
.hasStatus(HttpStatus.CREATED)
.hasHeader(HttpHeaders.LOCATION, "http://localhost/api/orders/42")
.bodyJson()
.isLenientlyEqualTo("""
{"id":42,"status":"PLACED","lines":[{"productId":1,"quantity":2}],"total":179.80}
""");
then(orderService).should().place("alice", List.of(new OrderItem(1L, 2)));
}
@Test
void invalidBodyIs422AndNeverReachesTheService() {
assertThat(mvc.post().uri("/api/orders")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"lines":[{"productId":1,"quantity":0}]}
"""))
.hasStatus(HttpStatus.UNPROCESSABLE_CONTENT)
.bodyJson()
.isLenientlyEqualTo("""
{"status":422,"errors":[{"field":"lines[0].quantity","message":"must be greater than 0"}]}
""");
then(orderService).shouldHaveNoInteractions();
}
@Test
void insufficientStockIs409() {
given(orderService.place(eq("alice"), anyList()))
.willThrow(new InsufficientStockException("KB-01", 1, 2));
assertThat(mvc.post().uri("/api/orders")
.with(user("alice"))
.contentType(MediaType.APPLICATION_JSON)
.content(TWO_KEYBOARDS))
.hasStatus(HttpStatus.CONFLICT)
.bodyJson()
.extractingPath("$.detail").asString().isEqualTo("Only 1 of KB-01 in stock, 2 requested");
}
@Test
void methodSecurityDenialIs403NotA500() {
given(orderService.findById(7L)).willThrow(new AuthorizationDeniedException("Access Denied"));
assertThat(mvc.get().uri("/api/orders/7").with(user("bob")))
.hasStatus(HttpStatus.FORBIDDEN)
.bodyJson()
.extractingPath("$.title").asString().isEqualTo("Forbidden");
}
@Test
void shippingNeedsTheAdminRole() {
assertThat(mvc.post().uri("/api/orders/7/ship").with(user("alice")))
.hasStatus(HttpStatus.FORBIDDEN);
then(orderService).shouldHaveNoInteractions();
}
private static JwtRequestPostProcessor user(String username) {
return jwt().jwt(token -> token.subject(username))
.authorities(new SimpleGrantedAuthority("ROLE_USER"));
}
private static Order order(Long id) {
Product keyboard = new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 8);
ReflectionTestUtils.setField(keyboard, "id", 1L);
Order order = new Order(new AppUser("alice", "alice@example.com", "hash"));
order.addLine(new OrderLine(keyboard, 2));
ReflectionTestUtils.setField(order, "id", id);
return order;
}
}@Import({SecurityConfig.class, ProblemDetailSecurityHandler.class})mang vào chain thật và không gì khác, chính là cách tách class ở phần security.placeWithAnInvalidTokenIs401gửi một headerAuthorizationthật qua resource server; khi import thêmAuthConfig, đây là test chết vìStackOverflowError.jwt().jwt(...).authorities(...)cho mỗi request mộtJwtAuthenticationToken; như bài 38 đã thấy, post-processor không ápauthorities-claim-name, nên role được đặt trực tiếp.methodSecurityDenialIs403NotA500để service đã mock ném exception mà một lần từ chối của@PreAuthorizetạo ra, và chứng minh rằng rethrow trong advice cùng access denied handler biến nó thành 403ProblemDetail, mà không cần proxy của method security trong slice.shippingNeedsTheAdminRoleđược URL rule quyết định trước khi controller chạy, nên service không bao giờ được gọi.
@DataJpaTest cho các query của order
package com.example.orders.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import com.example.orders.common.AuditingConfig;
import com.example.orders.product.Product;
import com.example.orders.user.AppUser;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
import org.springframework.boot.jpa.test.autoconfigure.TestEntityManager;
import org.springframework.context.annotation.Import;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
@DataJpaTest
@Import(AuditingConfig.class)
class OrderRepositoryTest {
@Autowired
TestEntityManager entityManager;
@Autowired
OrderRepository orders;
private Long bobsOrderId;
@BeforeEach
void setUp() {
AppUser alice = entityManager.persist(new AppUser("alice", "alice@example.com", "hash"));
AppUser bob = entityManager.persist(new AppUser("bob", "bob@example.com", "hash"));
Product keyboard = entityManager.persist(
new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
place(alice, keyboard, 1);
bobsOrderId = place(bob, keyboard, 2).getId();
place(alice, keyboard, 3);
entityManager.flush();
entityManager.clear();
}
@Test
void findsTheUsernameOfTheCustomerWhoOwnsAnOrder() {
assertThat(orders.findCustomerUsernameById(bobsOrderId)).contains("bob");
assertThat(orders.findCustomerUsernameById(999L)).isEmpty();
}
@Test
void pagesTheOrdersOfOneCustomerWithTheirLines() {
Page<Order> page = orders.findByCustomerUsername("alice", PageRequest.of(0, 1, Sort.by("id")));
entityManager.clear();
assertThat(page.getTotalElements()).isEqualTo(2);
assertThat(page.getTotalPages()).isEqualTo(2);
assertThat(page.getContent()).singleElement()
.satisfies(order -> assertThat(order.getLines())
.extracting(OrderLine::getQuantity)
.containsExactly(1));
}
private Order place(AppUser customer, Product product, int quantity) {
Order order = new Order(customer);
order.addLine(new OrderLine(product, quantity));
return entityManager.persist(order);
}
}@Import(AuditingConfig.class) không phải tùy chọn. @DataJpaTest chỉ scan các component JPA, AuditingConfig là một @Configuration, và thiếu nó thì không gì điền các cột audit. Cùng class đó khi không import:
org.hibernate.exception.ConstraintViolationException: could not execute statement [NULL not allowed for column "CREATED_AT"; SQL statement:
insert into products (created_at,name,price,sku,stock,updated_at,id) values (?,?,?,?,?,?,default) [23502-240]]Flyway chạy trong slice: log của test có Successfully applied 3 migrations to schema "PUBLIC" trên database embedded trước khi Hibernate khởi động, nên các query được test trên schema đã migrate. SQL mà query có phân trang gửi đi, với showSql bật như @DataJpaTest đặt:
Hibernate: select o1_0.id,o1_0.created_at,o1_0.customer_id,l1_0.order_id,l1_0.id,l1_0.product_id,l1_0.quantity,l1_0.unit_price,o1_0.status,o1_0.updated_at from (select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.status,o1_0.updated_at from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=? order by o1_0.id offset ? rows fetch first ? rows only) o1_0(id,created_at,customer_id,status,updated_at) left join order_lines l1_0 on o1_0.id=l1_0.order_id order by o1_0.id
Hibernate: select count(*) from orders o1_0 join users c1_0 on c1_0.id=o1_0.customer_id where c1_0.username=?Page gồm một order được cắt bên trong derived table rồi các line được join vào, và câu đếm không join tới order_lines: đúng các dạng mà bài 29 đã đo cho entity graph trên một derived query. entityManager.clear() ngay sau query tách các order khỏi persistence context, nên assertion trên các line chỉ pass khi chính query đã fetch chúng. Khi bỏ @EntityGraph khỏi findByCustomerUsername, cùng test đó fail với LazyInitializationException: Cannot lazily initialize collection of role 'com.example.orders.order.Order.lines' with key '4' (no session).
Test end-to-end trên port ngẫu nhiên
package com.example.orders.order;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import java.net.URI;
import java.util.List;
import com.example.orders.product.Product;
import com.example.orders.product.ProductRepository;
import com.example.orders.user.AppUserRepository;
import com.example.orders.user.LoginRequest;
import com.example.orders.user.RegisterRequest;
import com.example.orders.user.TokenResponse;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.resttestclient.autoconfigure.AutoConfigureRestTestClient;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.client.RestTestClient;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
@AutoConfigureRestTestClient
class OrderApiTest {
@Autowired
RestTestClient client;
@Autowired
OrderRepository orders;
@Autowired
ProductRepository products;
@Autowired
AppUserRepository users;
private Product keyboard;
@BeforeEach
void createProduct() {
keyboard = products.save(new Product("Mechanical keyboard", "KB-01", new BigDecimal("89.90"), 10));
}
@AfterEach
void deleteWhatTheServerCommitted() {
orders.deleteAll();
products.deleteAll();
users.deleteAll();
}
@Test
void customerRegistersPlacesAnOrderAndReadsItBack() {
String carol = registerAndLogIn("carol");
URI location = client.post().uri("/api/orders")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
.contentType(MediaType.APPLICATION_JSON)
.body(new PlaceOrderRequest(List.of(new PlaceOrderRequest.Line(keyboard.getId(), 3))))
.exchange()
.expectStatus().isCreated()
.returnResult()
.getResponseHeaders().getLocation();
client.get().uri(location)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + carol)
.exchange()
.expectStatus().isOk()
.expectBody()
.jsonPath("$.status").isEqualTo("PLACED")
.jsonPath("$.total").isEqualTo(269.70);
assertThat(products.findById(keyboard.getId())).get()
.extracting(Product::getStock).isEqualTo(7);
String dave = registerAndLogIn("dave");
client.get().uri(location)
.header(HttpHeaders.AUTHORIZATION, "Bearer " + dave)
.exchange()
.expectStatus().isForbidden();
}
private String registerAndLogIn(String username) {
String password = "Secret-" + username + "-2026";
client.post().uri("/api/auth/register")
.contentType(MediaType.APPLICATION_JSON)
.body(new RegisterRequest(username, username + "@example.com", password))
.exchange()
.expectStatus().isCreated();
return client.post().uri("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.body(new LoginRequest(username, password))
.exchange()
.expectStatus().isOk()
.expectBody(TokenResponse.class)
.returnResult()
.getResponseBody()
.accessToken();
}
}Không có gì bị giả lập: BCrypt kiểm tra password của carol, TokenService ký bằng secrets/private.pem, decoder xác minh bằng secrets/public.pem, service lock row của product, và @PreAuthorize từ chối dave. Server commit trên các thread của nó, nên test xóa order, product và user sau đó thay vì dựa vào @Transactional, như bài 38 đã cho thấy.
Chạy bộ test
./gradlew testOrdersApplicationTests > contextLoads() PASSED
OrderApiTest > customerRegistersPlacesAnOrderAndReadsItBack() PASSED
OrderControllerTest > methodSecurityDenialIs403NotA500() PASSED
OrderControllerTest > invalidBodyIs422AndNeverReachesTheService() PASSED
OrderControllerTest > placeWithAnInvalidTokenIs401() PASSED
OrderControllerTest > placeWithoutATokenIs401() PASSED
OrderControllerTest > shippingNeedsTheAdminRole() PASSED
OrderControllerTest > placeAnswers201WithTheLocationOfTheOrder() PASSED
OrderControllerTest > insufficientStockIs409() PASSED
OrderRepositoryTest > findsTheUsernameOfTheCustomerWhoOwnsAnOrder() PASSED
OrderRepositoryTest > pagesTheOrdersOfOneCustomerWithTheirLines() PASSED
OrderServiceTest > cancelPutsTheStockBack() PASSED
OrderServiceTest > shipRefusesACancelledOrder() PASSED
OrderServiceTest > placeTakesStockForEveryLineAndSavesTheOrder() PASSED
OrderServiceTest > cancelRefusesAShippedOrderAndKeepsTheStock() PASSED
OrderServiceTest > placeSavesNothingWhenALineHasTooLittleStock() PASSED
BUILD SUCCESSFUL in 5sThời gian từng class lấy từ report JUnit của lần nhanh nhất trong ba lần chạy ./gradlew test --rerun, với load average một phút là 7.0; các con số chỉ mang tính tham khảo:
| Test class | Loại | Số test | Thời gian |
|---|---|---|---|
OrdersApplicationTests | @SpringBootTest, test context được sinh sẵn | 1 | 2.485 s |
OrderApiTest | @SpringBootTest(webEnvironment = RANDOM_PORT) | 1 | 1.141 s |
OrderControllerTest | @WebMvcTest kèm security chain | 7 | 0.301 s |
OrderRepositoryTest | @DataJpaTest | 2 | 0.229 s |
OrderServiceTest | JUnit và Mockito thuần | 5 | 0.131 s |
| Tổng | 16 | 4.287 s |
Class đầu tiên trả giá cho JVM còn lạnh và application context đầu tiên; web slice và JPA slice dựng các context nhỏ hơn trong JVM đã ấm.
Docker: image, Compose và JWT key
Dockerfile là phiên bản multi-stage cuối cùng của bài 41, với cache mount của BuildKit, giữ nguyên:
FROM eclipse-temurin:21-jdk AS build
WORKDIR /workspace
COPY gradlew settings.gradle build.gradle ./
COPY gradle gradle
RUN ./gradlew dependencies --no-daemon > /dev/null
COPY src src
RUN --mount=type=cache,target=/root/.gradle/caches ./gradlew bootJar -x test --no-daemon
FROM eclipse-temurin:21-jre
RUN groupadd --system spring && useradd --system --gid spring --no-create-home spring
WORKDIR /app
COPY --from=build /workspace/build/libs/*.jar app.jar
USER spring
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar"]Nó chỉ copy gradlew, các file build, gradle và src, nên key trong secrets không bao giờ đi vào quá trình build. .dockerignore là của bài 41 thêm hai file cục bộ, để không lệnh COPY nào sau này nhặt được chúng:
.git
.gradle
.idea
build/*
!build/libs/
secrets
.envcompose.yaml sửa từ bài 41
services:
db:
image: postgres:18
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop
POSTGRES_PASSWORD: secret
volumes:
- db-data:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-h", "localhost", "-U", "shop", "-d", "shop"]
interval: 2s
timeout: 3s
retries: 15
app:
build: .
depends_on:
db:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: postgres
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/shop
SPRING_DATASOURCE_PASSWORD: secret
SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATION: file:/run/secrets/jwt-public-key
APP_JWT_PRIVATE_KEY_LOCATION: file:/run/secrets/jwt-private-key
APP_ADMIN_PASSWORD: ${APP_ADMIN_PASSWORD:?set APP_ADMIN_PASSWORD in .env}
secrets:
- jwt-public-key
- jwt-private-key
ports:
- "8141:8080"
- "8142:8080"
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8080/actuator/health"]
interval: 5s
timeout: 3s
retries: 12
start_period: 10s
secrets:
jwt-public-key:
file: ./secrets/public.pem
jwt-private-key:
file: ./secrets/private.pem
volumes:
db-data:- Hai environment variable trỏ các property của key tới
/run/secrets, nơi Compose mount từng mục trong danh sáchsecretscủa service theo tên của nó. Relaxed binding mapSPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_PUBLIC_KEY_LOCATIONvào property mà cả decoder của Boot lẫn@ValuetrongAuthConfigđọc. ${APP_ADMIN_PASSWORD:?...}khiến Compose từ chối khởi động khi thiếu biến này, biến mà Compose đọc từ file.envđặt cạnhcompose.yaml; các lần chạy bên dưới dùng giá trị trong file này, và.gitignoregiữ file ngoài Git.- Password của database vẫn nằm trong file, như bài 41: PostgreSQL không publish port nào, còn password admin bảo vệ một API được publish trên 8142.
APP_ADMIN_PASSWORD=Admin-2026-secretKhi không có file đó, up dừng trước khi build bất cứ thứ gì:
Error while interpolating services.app.environment.APP_ADMIN_PASSWORD: required variable APP_ADMIN_PASSWORD is missing a value: set APP_ADMIN_PASSWORD in .envĐưa JWT key vào container bằng Compose secrets
Private key ký mọi token mà API chấp nhận, nên nó phải có mặt trong container đang chạy mà không nằm trong image, nơi bất cứ ai pull được image đều đọc được. Compose secrets với file: làm đúng việc đó: file ở lại trên host và được mount chỉ đọc vào container lúc khởi động. Bên trong container ứng dụng đang chạy:
docker exec sb-a42-app-1 id
docker exec sb-a42-app-1 ls -l /run/secrets/
docker exec sb-a42-app-1 sh -c 'mount | grep secrets'uid=999(spring) gid=999(spring) groups=999(spring)
total 8
-rw------- 1 spring spring 1708 Sep 16 09:05 jwt-private-key
-rw-r--r-- 1 spring spring 451 Sep 16 09:05 jwt-public-key
/run/host_mark/private on /run/secrets/jwt-public-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)
/run/host_mark/private on /run/secrets/jwt-private-key type fakeowner (ro,nosuid,nodev,relatime,fakeowner)Cả hai file là mount chỉ đọc, và mode 600 của openssl genpkey được giữ nguyên. Chúng hiện là của spring vì cơ chế chia sẻ file của Docker Desktop trình bày file của host với owner là process đang đọc chúng, chính là kiểu mount fakeowner ở trên; đó là lý do user không phải root của bài 41 đọc được một file 600 do user khác tạo. Trên host Linux, bind mount giữ owner dạng số thật của file. Cùng private key đó chép vào một Docker volume, nơi quyền sở hữu là thật, thuộc uid 1000 với mode 600 và mount chỉ đọc tại /run/secrets trong một container chạy bằng uid 999, đã không đọc được:
head: cannot open '/run/secrets/private.pem' for reading: Permission deniedTrên Linux, hãy giao key cho uid 999 hoặc một group mà user đó thuộc về trước khi khởi động stack. Cú pháp dài của Compose có uid, gid và mode cho secrets, nhưng Compose 5.3.1 bỏ qua chúng với secret dạng file và nói rõ như vậy: secrets 'uid', 'gid' and 'mode' are not supported, they will be ignored. Ở production, cũng hai file đó đến từ secret store của nền tảng, như Kubernetes Secrets hay secret manager của cloud, được mount thành file hoặc do nền tảng inject, không bao giờ từ repository hay image.
Khởi động stack
docker compose -p sb-a42 up --build -dSau các bước BuildKit, mất 55 giây khi build cache còn trống với load average 3.3:
Image sb-a42-app Built
Network sb-a42_default Creating
Network sb-a42_default Created
Volume sb-a42_db-data Creating
Volume sb-a42_db-data Created
Container sb-a42-db-1 Creating
Container sb-a42-db-1 Created
Container sb-a42-app-1 Creating
Container sb-a42-app-1 Created
Container sb-a42-db-1 Starting
Container sb-a42-db-1 Started
Container sb-a42-db-1 Waiting
Container sb-a42-db-1 Healthy
Container sb-a42-app-1 Starting
Container sb-a42-app-1 Started Build lại sau khi sửa một file source, chính là AuditingConfig ở trên, mất 9.7 giây với docker compose -p sb-a42 down -v rồi cùng lệnh up --build -d: mọi layer tới COPY src src lấy từ cache, và bootJar với cache mount chạy trong 4.0 giây.
docker compose -p sb-a42 psNAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
sb-a42-app-1 sb-a42-app "java -jar /app/app.…" app 13 seconds ago Up 10 seconds (healthy) 0.0.0.0:8142->8080/tcp, [::]:8142->8080/tcp
sb-a42-db-1 postgres:18 "docker-entrypoint.s…" db 13 seconds ago Up 12 seconds (healthy) 5432/tcpdocker compose -p sb-a42 logs appCác dòng cho thấy profile, migration và administrator:
app-1 | 2026-09-16T09:19:04.953Z INFO 1 --- [orders] [ main] com.example.orders.OrdersApplication : The following 1 profile is active: "postgres"
app-1 | 2026-09-16T09:19:05.975Z INFO 1 --- [orders] [ main] org.flywaydb.core.FlywayExecutor : Database: jdbc:postgresql://db:5432/shop (PostgreSQL 18.6)
app-1 | 2026-09-16T09:19:06.055Z INFO 1 --- [orders] [ main] o.f.core.internal.command.DbMigrate : Successfully applied 3 migrations to schema "public", now at version v3 (execution time 00:00.006s)
app-1 | 2026-09-16T09:19:07.526Z INFO 1 --- [orders] [ main] com.example.orders.OrdersApplication : Started OrdersApplication in 2.732 seconds (process running for 2.981)
app-1 | 2026-09-16T09:19:07.676Z INFO 1 --- [orders] [ main] c.e.orders.user.AdminAccountInitializer : Created admin account 'admin'Started OrdersApplication cũng có nghĩa là ddl-auto=validate đã chấp nhận schema đã migrate trên PostgreSQL. docker images sb-a42-app báo 606MB dung lượng trên đĩa và 176MB nội dung, nhiều hơn image catalogue của bài 41 10 MB nội dung; app.jar ở đây là 69,402,210 byte so với 58,557,202 byte ở đó, với Security, springdoc và Actuator bên trong.

Chạy API từ đầu tới cuối bằng curl
Các request dưới đây chạy theo đúng thứ tự này trên stack mới dựng, nên các id nối tiếp nhau: administrator có id 1, alice 2 và bob 3. Các security header như X-Frame-Options và Cache-Control, cùng Date, Transfer-Encoding và Content-Length, được lược khỏi output của curl -i.
Đăng ký, đăng nhập và tạo product
curl -s -i -H 'Content-Type: application/json' -d '{"username":"alice","email":"alice@example.com","password":"Wonderland-2026"}' http://localhost:8142/api/auth/registerHTTP/1.1 201
Content-Type: application/json
{"id":2,"username":"alice","email":"alice@example.com","role":"USER"}bob đăng ký theo cùng cách, với password Builder-2026-bob, và nhận id 3. Đăng nhập:
curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login{"accessToken":"eyJraWQiOiJ1cWU1bnBBb1ZCYVhDYnBfVHZ0MVZYV3VuSzhSQ0I4QTFGUXVWMElPRjNNIiwidHlwIjoiSldUIiwiYWxnIjoiUlMyNTYifQ.eyJpc3MiOiJodHRwczovL29yZGVycy5leGFtcGxlLmNvbSIs…","tokenType":"Bearer","expiresIn":900}Shell giữ một token cho mỗi user; password admin là password trong .env:
ALICE=$(curl -s -H 'Content-Type: application/json' -d '{"username":"alice","password":"Wonderland-2026"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
BOB=$(curl -s -H 'Content-Type: application/json' -d '{"username":"bob","password":"Builder-2026-bob"}' http://localhost:8142/api/auth/login | jq -r .accessToken)
ADMIN=$(curl -s -H 'Content-Type: application/json' -d '{"username":"admin","password":"Admin-2026-secret"}' http://localhost:8142/api/auth/login | jq -r .accessToken)Giải mã bằng b64url_decode của bài 35, payload trong token của alice và của administrator là:
{"iss":"https://orders.example.com","sub":"alice","exp":1789551255,"iat":1789550355,"roles":["USER"]}
{"iss":"https://orders.example.com","sub":"admin","exp":1789551255,"iat":1789550355,"roles":["ADMIN"]}Administrator tạo một bàn phím với 10 cái trong stock, rồi một con chuột với 3 và một USB-C hub với 5 theo cùng cách:
curl -s -i -H "Authorization: Bearer $ADMIN" -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10}' http://localhost:8142/api/productsHTTP/1.1 201
Location: http://localhost:8142/api/products/1
Content-Type: application/json
{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"}Ai cũng đọc được catalogue, mỗi lần một page:
curl -s -i "http://localhost:8142/api/products?size=2&sort=price,desc"HTTP/1.1 200
Content-Type: application/json
{"content":[{"id":1,"name":"Mechanical keyboard","sku":"KB-01","price":89.90,"stock":10,"createdAt":"2026-09-16T09:19:15.316336Z","updatedAt":"2026-09-16T09:19:15.316336Z"},{"id":3,"name":"USB-C hub","sku":"HUB-07","price":39.00,"stock":5,"createdAt":"2026-09-16T09:19:15.346947Z","updatedAt":"2026-09-16T09:19:15.346947Z"}],"page":0,"size":2,"totalElements":3,"totalPages":2,"hasNext":true}Đặt order và các response khi thất bại
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":2,"quantity":1},{"productId":1,"quantity":2}]}' http://localhost:8142/api/ordersHTTP/1.1 201
Location: http://localhost:8142/api/orders/1
Content-Type: application/json
{"id":1,"customerId":2,"status":"PLACED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.401168Z"}Stock sau đó:
curl -s "http://localhost:8142/api/products?sort=id" | jq -c '[.content[] | {sku, stock}]'[{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]Một order gồm một bàn phím và năm con chuột, khi chỉ còn hai con chuột:
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1},{"productId":2,"quantity":5}]}' http://localhost:8142/api/ordersHTTP/1.1 409
Content-Type: application/problem+json
{"detail":"Only 2 of MS-01 in stock, 5 requested","instance":"/api/orders","status":409,"title":"Conflict"}Cùng query stock đó trả [{"sku":"KB-01","stock":8},{"sku":"MS-01","stock":2},{"sku":"HUB-07","stock":5}]: line bàn phím đã được xử lý trước, và rollback không để lại dấu vết nào của nó. Một body không hợp lệ:
curl -s -i -H "Authorization: Bearer $ALICE" -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":0},{"quantity":2}]}' http://localhost:8142/api/ordersHTTP/1.1 422
Content-Type: application/problem+json
{"detail":"Request has 2 invalid value(s).","instance":"/api/orders","status":422,"title":"Unprocessable Content","errors":[{"field":"lines[0].quantity","message":"must be greater than 0"},{"field":"lines[1].productId","message":"must not be null"}]}Không có token:
curl -s -i -H 'Content-Type: application/json' -d '{"lines":[{"productId":1,"quantity":1}]}' http://localhost:8142/api/ordersHTTP/1.1 401
WWW-Authenticate: Bearer realm="orders", resource_metadata="http://localhost:8142/.well-known/oauth-protected-resource"
Content-Type: application/problem+json
{"detail":"Valid credentials are required to access this resource.","instance":"/api/orders","status":401,"title":"Unauthorized"}Ownership, hủy order và ship order
bob xin order của alice:
curl -s -i -H "Authorization: Bearer $BOB" http://localhost:8142/api/orders/1HTTP/1.1 403
Content-Type: application/problem+json
{"detail":"You are not allowed to perform this operation.","instance":"/api/orders/1","status":403,"title":"Forbidden"}Cùng request đó với token của alice trả 200 kèm order, còn GET /api/orders của bob trả {"content":[],"page":0,"size":20,"totalElements":0,"totalPages":0,"hasNext":false}. alice hủy order:
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/1/cancelHTTP/1.1 200
Content-Type: application/json
{"id":1,"customerId":2,"status":"CANCELLED","lines":[{"productId":1,"quantity":2,"unitPrice":89.90,"lineTotal":179.80},{"productId":2,"quantity":1,"unitPrice":24.50,"lineTotal":24.50}],"total":204.30,"createdAt":"2026-09-16T09:19:15.401168Z","updatedAt":"2026-09-16T09:19:15.565838Z"}Stock quay về đúng như lúc đầu, [{"sku":"KB-01","stock":10},{"sku":"MS-01","stock":3},{"sku":"HUB-07","stock":5}], và updatedAt là thời điểm hủy. Sau đó alice đặt một cái hub, order 2. Cô không tự ship được: POST /api/orders/2/ship với token của cô nhận 403 của URL rule. Administrator thì được:
curl -s -i -X POST -H "Authorization: Bearer $ADMIN" http://localhost:8142/api/orders/2/shipHTTP/1.1 200
Content-Type: application/json
{"id":2,"customerId":2,"status":"SHIPPED","lines":[{"productId":3,"quantity":1,"unitPrice":39.00,"lineTotal":39.00}],"total":39.00,"createdAt":"2026-09-16T09:19:15.590799Z","updatedAt":"2026-09-16T09:19:15.616806Z"}Và một order đã ship thì không hủy được nữa:
curl -s -i -X POST -H "Authorization: Bearer $ALICE" http://localhost:8142/api/orders/2/cancelHTTP/1.1 409
Content-Type: application/problem+json
{"detail":"Order 2 is SHIPPED and cannot be cancelled","instance":"/api/orders/2/cancel","status":409,"title":"Conflict"}Khi xong việc, docker compose -p sb-a42 down -v --rmi local xóa các container, network, volume và image đã build cho stack.
Đi tiếp từ đây
Dự án tổng kết dừng ở nơi khóa Advanced bắt đầu. Mỗi mục dưới đây xây trực tiếp trên một phần ở trên:
- Query ở quy mô lớn: phát hiện N+1 và batch fetching, projection thay cho entity trong các danh sách, Specification cho bộ lọc, keyset pagination cho các page sâu.
- Test trên database thật với Testcontainers và
@ServiceConnection, thay cho H2 và những bất ngờ của nó. - Caching cho việc đọc product với cache abstraction của Spring.
- Observability: metric với Micrometer, tracing và log có cấu trúc, cùng Actuator ngoài
health. - Token có vòng đời: refresh token và authorization server, thứ mà token 15 phút của API này cố ý bỏ qua.
- Delivery: layered JAR và buildpack, Docker Compose support của Spring Boot cho môi trường phát triển, pipeline CI, native image GraalVM và Kubernetes.
- Tách hệ thống: event, outbox và microservice khi order và product lớn quá một ứng dụng.
Upload ảnh product, email xác nhận order và các job theo lịch của bài 40 có thể gắn vào cùng các package feature mà không thay đổi gì đã trình bày ở đây.
FAQ
Với JWT, nên tạo order dưới URL của customer hay dưới /api/orders?
Với bearer token, hãy dùng POST /api/orders cho user đã đăng nhập. Subject của token đã xác định customer; một customer id trên URL chỉ có thể lặp lại hoặc mâu thuẫn với nó, và một mâu thuẫn chỉ có thể được trả lời bằng 403. Giữ URL riêng của order trong Location, ở đây là http://localhost:8142/api/orders/1, và cho administrator truy cập order của user khác qua chính các URL đó kèm kiểm tra role.
Làm sao ngăn các order đồng thời bán quá stock trong Spring Boot?
Lock row của product bên trong transaction trước khi đọc stock. Không có lock, hai mươi order song song cho năm món hàng nhận lần lượt 20, 18 và 20 response 201 trong ba lần chạy trên PostgreSQL 18, và một lần chạy còn kết thúc với stock 2. Với @Lock(LockModeType.PESSIMISTIC_WRITE) trên method của repository, được Hibernate 7.4.5 gửi thành for no key update, đúng năm request thành công và mười lăm request nhận 409. Lock nhiều product theo một thứ tự cố định, ví dụ theo id, để hai order không thể deadlock.
Vì sao @WebMvcTest ném StackOverflowError với một bearer token không hợp lệ?
Vì class cấu hình được import còn khai báo bean AuthenticationManager từ AuthenticationConfiguration.getAuthenticationManager(). Trong slice không có UserDetailsService, nên bean đó trở thành một lazy proxy tự gọi chính nó khi ProviderManager của resource server hỏi manager cha. Đặt filter chain vào một class và các bean authentication cùng token vào class khác, rồi chỉ import chain: khi đó cùng test nhận được 401.
Làm sao đưa JWT private key vào container Docker mà không nhét nó vào image?
Giữ key ngoài src/main/resources và ngoài build context, rồi mount nó lúc chạy. Với Compose secrets và file: ./secrets/private.pem, key xuất hiện chỉ đọc tại /run/secrets/jwt-private-key, và APP_JWT_PRIVATE_KEY_LOCATION=file:/run/secrets/jwt-private-key trỏ ứng dụng tới đó. Trên host Linux file được mount giữ owner và mode của nó, nên hãy làm cho user của container đọc được; ở production, dùng secret store của nền tảng.
Vì sao Swagger UI hiện object pageable thay vì page, size và sort?
springdoc-openapi 3.1.1 mô tả một tham số Pageable thường của controller thành một query parameter bắt buộc tên pageable với schema dạng object. Gắn @ParameterObject lên nó thì operation liệt kê page, size và sort, kèm giá trị mặc định từ @PageableDefault.
Vì sao @DataJpaTest fail với NULL not allowed for column CREATED_AT?
@DataJpaTest không nạp các class @Configuration, nên thiếu @EnableJpaAuditing và AuditingEntityListener không điền gì cả. Thêm @Import với class cấu hình auditing vào test, như OrderRepositoryTest làm.
Kết luận
Dự án tổng kết là cả khóa học trong một ứng dụng. Các package feature chứa controller map DTO, service giữ rule và repository fetch đúng những gì mỗi endpoint cần; Flyway dựng schema mà Hibernate validate; ProblemDetail trả lời mọi lỗi với status mà bài 15 đã chọn cho nó; JWT xác định người gọi, URL rule quyết định theo path và @PreAuthorize quyết định theo chủ sở hữu; springdoc mô tả API kèm bearer scheme; test phủ các rule, tầng web cùng security, các query và toàn bộ stack; và Compose chạy tất cả trên PostgreSQL với key được mount lúc chạy.
Những phần thú vị nằm ở chỗ nối. Chỉ riêng @Transactional giữ được một order trọn vẹn nhưng vẫn để hai mươi order đồng thời bán năm cái hub, cho tới khi row của product được lock theo thứ tự id. Một web slice import cả các bean authentication thì sập với token không hợp lệ, và một JPA slice thiếu cấu hình auditing thì không insert nổi một row. Pageable cần @ParameterObject để dùng được trong Swagger UI, đồng hồ của container cần được cắt xuống mức PostgreSQL lưu, H2 cần bỏ các check chuỗi, và private key cần một đường vào container không phải là image.
Bài này khép lại Spring Boot Basics: từ phần Java mà ứng dụng Spring cần, qua bean, cấu hình, REST, dữ liệu, security, test và đóng gói, tới một API hoàn chỉnh. Khóa Advanced tiếp tục từ đây, bắt đầu với những câu hỏi mà API này đặt ra khi chạy ở quy mô lớn.