Spring REST API & Data JPA/Mini-challenge: TaskFlow REST API v1 — capstone phần REST API
20/46
Bài 20 / 46~35 phútError, Validation & API DocsMiễn phí lượt xem

Mini-challenge: TaskFlow REST API v1 — capstone phần REST API

Build TaskFlow REST API v1 từ scratch — domain Project + Task. 8 endpoint CRUD, validation, exception handling Problem Details, OpenAPI doc, in-memory storage. Đây là baseline cho phần JPA và course Spring Security extend lên.

TL;DR: Bài này không dạy khái niệm mới — bạn build TaskFlow REST API v1 từ scratch, áp dụng toàn bộ 7 concept phần REST API: @RestController, request binding, response, exception handling Problem Details, validation, OpenAPI. Sản phẩm là app taskflow-api 8 endpoint CRUD với in-memory storage, kiến trúc 3 layer testable. Đây là baseline mà phần JPA (TaskFlow v2) và các course sau (Security, Testing, Production) extend lên — đầu tư cấu trúc đúng ở đây trả lương cho mọi phần sau.

TaskFlow là capstone domain xuyên suốt track Spring: phần REST API build CRUD in-memory (bài này), phần JPA thêm Postgres + transactions, các course sau lần lượt thêm JWT auth, test pyramid Testcontainers, observability, rồi microservices. Khi xong khoá, bạn có 1 production-grade Spring Boot app — không phải toy code.

🎯 Đề bài

Build app taskflow-api với:

Domain

Project (root entity)
├── id: Long
├── name: String (3-100 char, unique)
├── description: String? (max 500 char)
├── status: ProjectStatus (PLANNING / ACTIVE / DONE / ARCHIVED)
├── createdAt: Instant
└── tasks: List<Task>

Task
├── id: Long
├── projectId: Long
├── title: String (3-200 char)
├── description: String? (max 1000 char)
├── status: TaskStatus (TODO / IN_PROGRESS / DONE)
├── priority: TaskPriority (LOW / MEDIUM / HIGH / URGENT)
├── dueDate: LocalDate?
├── createdAt: Instant
└── updatedAt: Instant

8 Endpoints

POST   /api/v1/projects                    Create project
GET    /api/v1/projects                    List projects (page, filter)
GET    /api/v1/projects/{id}               Get project + tasks
PUT    /api/v1/projects/{id}               Update project
DELETE /api/v1/projects/{id}               Delete project (cascade tasks)

POST   /api/v1/projects/{id}/tasks         Create task in project
GET    /api/v1/projects/{id}/tasks         List tasks of project (filter by status)
PATCH  /api/v1/projects/{pid}/tasks/{tid}  Partial update task

Yêu cầu kỹ thuật

  1. Boot 3.4 + Java 21 + Maven.
  2. Storage: in-memory ConcurrentHashMap (phần JPA sẽ migrate sang JPA).
  3. 3 layer: Controller → Service → Repository (interface + in-memory impl).
  4. DTO: separate *Request (input) và *Dto (output) — record.
  5. Validation: Jakarta Bean Validation đầy đủ + cross-field qua @AssertTrue.
  6. Exception handling: @RestControllerAdvice global + Problem Details RFC 9457 + violations field.
  7. OpenAPI: springdoc-openapi với @Operation, @Schema example.
  8. Status code đúng: 201 Created với Location, 204 No Content cho DELETE, 404/409/400 chuẩn.
  9. MDC correlation ID: filter tự gen X-Request-Id, log mọi request.
  10. Unit test: MockMvc cho 3-5 happy path + edge case.

✅ Checklist hoàn thành

Đánh dấu từng mục trước khi coi challenge là xong. [Bắt buộc] là tối thiểu để qua bài; [Bonus] dành cho người muốn đẩy xa hơn.

  • [Bắt buộc] 8 endpoint chạy đúng HTTP method + status code chuẩn (201 + Location, 204 cho DELETE, 404/409/400).
  • [Bắt buộc] 3 layer tách bạch: Controller không chứa business logic, Repository là interface + in-memory impl.
  • [Bắt buộc] DTO record tách *Request / *Dto — entity không bao giờ lộ ra response.
  • [Bắt buộc] Validation Jakarta đầy đủ; lỗi trả Problem Details RFC 9457 kèm field violations.
  • [Bắt buộc] @RestControllerAdvice map domain exception (404/409) — domain exception là POJO thuần.
  • [Bắt buộc] Test workflow 8 bước bằng curl (section ✅ Test workflow) pass toàn bộ.
  • [Bonus] Swagger UI mô tả đủ 8 endpoint với @Operation + @Schema example.
  • [Bonus] MDC correlation ID filter + MockMvc test slice (mức 1 phần Mở rộng).

🔍 Phân tích kiến trúc

flowchart LR
    Client[HTTP Client]
    F["LoggingFilter<br/>(MDC)"]
    DS["DispatcherServlet"]
    Adv["GlobalExceptionHandler<br/>@RestControllerAdvice"]
    PC["ProjectController"]
    TC["TaskController"]
    PS["ProjectService"]
    TS["TaskService"]
    PR["ProjectRepository<br/>(in-memory)"]
    TR["TaskRepository<br/>(in-memory)"]
    Storage[("ConcurrentHashMap")]

    Client --> F --> DS
    DS --> PC
    DS --> TC
    DS -.exception.-> Adv
    PC --> PS
    TC --> TS
    PS --> PR
    TS --> TR
    PR --> Storage
    TR --> Storage

    style F fill:#fef3c7
    style Adv fill:#fee

Layered architecture:

LayerResponsibilityTest scope
ControllerHTTP binding, validation, status codeMockMvc test
ServiceBusiness logic, orchestrationUnit test với mock repo
RepositoryData access (in-memory)Unit test trực tiếp
DomainEntity records (immutable)Pure Java test
DTORequest/Response shapeValidation test
FilterCross-cutting (logging, MDC)Integration test

Phần JPA sẽ refactor Repository → JPA interface — Service layer không đổi (Liskov substitution).

📦 Concept dùng trong bài

ConceptModuleÁp dụng ở đây
@SpringBootApplicationM02Entry point
Auto-configurationM02Tomcat, Jackson tự setup
Externalized configM02application.yml
Logging + MDCM02Correlation ID per request
DispatcherServletM03Request routing
@RestControllerM03API endpoints
Request bindingM03@PathVariable, @RequestBody
Response handlingM03ResponseEntity, status code chuẩn
Exception handlingM03@ControllerAdvice + Problem Details
ValidationM03@Valid + cross-field
OpenAPIM03springdoc + @Operation

▶️ Cấu trúc project

taskflow-api/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/olhub/taskflow/
    │   │   ├── App.java
    │   │   ├── config/
    │   │   │   ├── OpenApiConfig.java
    │   │   │   └── LoggingFilter.java
    │   │   ├── domain/
    │   │   │   ├── Project.java                  (record)
    │   │   │   ├── ProjectStatus.java            (enum)
    │   │   │   ├── Task.java                      (record)
    │   │   │   ├── TaskStatus.java                (enum)
    │   │   │   └── TaskPriority.java              (enum)
    │   │   ├── api/
    │   │   │   ├── ProjectController.java
    │   │   │   ├── TaskController.java
    │   │   │   ├── GlobalExceptionHandler.java
    │   │   │   └── dto/
    │   │   │       ├── CreateProjectRequest.java
    │   │   │       ├── UpdateProjectRequest.java
    │   │   │       ├── ProjectDto.java
    │   │   │       ├── CreateTaskRequest.java
    │   │   │       ├── PatchTaskRequest.java
    │   │   │       ├── TaskDto.java
    │   │   │       └── ErrorViolation.java
    │   │   ├── service/
    │   │   │   ├── ProjectService.java
    │   │   │   └── TaskService.java
    │   │   ├── repository/
    │   │   │   ├── ProjectRepository.java         (interface)
    │   │   │   ├── InMemoryProjectRepository.java
    │   │   │   ├── TaskRepository.java
    │   │   │   └── InMemoryTaskRepository.java
    │   │   └── exception/
    │   │       ├── ProjectNotFoundException.java
    │   │       ├── TaskNotFoundException.java
    │   │       ├── DuplicateProjectNameException.java
    │   │       └── InvalidStateTransitionException.java
    │   └── resources/
    │       └── application.yml
    └── test/
        └── java/com/olhub/taskflow/
            └── api/
                ├── ProjectControllerTest.java
                └── TaskControllerTest.java

Dành 35-45 phút build skeleton. Hint chi tiết ở dưới khi kẹt.

💡 Gợi ý — code chính

💡 Hint — đọc khi kẹt

pom.xml:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-validation</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springdoc</groupId>
        <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
        <version>2.6.0</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Domain — Project.java:

public record Project(
    Long id,
    String name,
    String description,
    ProjectStatus status,
    Instant createdAt
) {}

public enum ProjectStatus { PLANNING, ACTIVE, DONE, ARCHIVED }

DTO — CreateProjectRequest.java:

@Schema(description = "Create project request")
public record CreateProjectRequest(

    @Schema(description = "Project name (unique)", example = "Mobile App Redesign", minLength = 3, maxLength = 100)
    @NotBlank @Size(min = 3, max = 100)
    String name,

    @Schema(description = "Project description", example = "Redesign mobile app for v3", maxLength = 500)
    @Size(max = 500)
    String description,

    @Schema(description = "Initial status", example = "PLANNING")
    ProjectStatus status
) {
    public CreateProjectRequest {
        if (status == null) status = ProjectStatus.PLANNING;
    }
}

Service — ProjectService.java:

@Service
@Slf4j
public class ProjectService {

    private final ProjectRepository repo;
    private final TaskRepository taskRepo;

    public ProjectService(ProjectRepository repo, TaskRepository taskRepo) {
        this.repo = repo;
        this.taskRepo = taskRepo;
    }

    public Project create(CreateProjectRequest req) {
        if (repo.existsByName(req.name())) {
            throw new DuplicateProjectNameException(req.name());
        }
        Project project = new Project(
            null,                                  // ID auto-gen
            req.name(),
            req.description(),
            req.status(),
            Instant.now()
        );
        Project saved = repo.save(project);
        log.info("Created project {}", saved.id());
        return saved;
    }

    public Project findById(Long id) {
        return repo.findById(id)
            .orElseThrow(() -> new ProjectNotFoundException(id));
    }

    public Page<Project> list(ProjectStatus status, Pageable pageable) {
        return repo.findByStatus(status, pageable);
    }

    public Project update(Long id, UpdateProjectRequest req) {
        Project existing = findById(id);
        if (!existing.name().equals(req.name()) && repo.existsByName(req.name())) {
            throw new DuplicateProjectNameException(req.name());
        }
        Project updated = new Project(
            id,
            req.name(),
            req.description(),
            req.status(),
            existing.createdAt()
        );
        return repo.save(updated);
    }

    @Transactional
    public void delete(Long id) {
        findById(id);                            // validate exists (throws 404 if not)
        long taskCount = taskRepo.countByProjectId(id);   // count BEFORE delete
        taskRepo.deleteByProjectId(id);          // cascade
        repo.delete(id);
        log.info("Deleted project {} with {} tasks", id, taskCount);
    }
}

Controller — ProjectController.java:

@RestController
@RequestMapping("/api/v1/projects")
@Tag(name = "Projects", description = "Project management")
@Validated
public class ProjectController {

    private final ProjectService service;

    public ProjectController(ProjectService service) {
        this.service = service;
    }

    @Operation(summary = "Create project")
    @PostMapping
    public ResponseEntity<ProjectDto> create(@Valid @RequestBody CreateProjectRequest req) {
        Project created = service.create(req);
        URI location = ServletUriComponentsBuilder.fromCurrentRequest()
            .path("/{id}").buildAndExpand(created.id()).toUri();
        return ResponseEntity.created(location).body(ProjectDto.from(created));
    }

    @Operation(summary = "List projects")
    @GetMapping
    public Page<ProjectDto> list(
        @Parameter(example = "ACTIVE")
        @RequestParam(required = false) ProjectStatus status,
        @Parameter(example = "0") @Min(0)
        @RequestParam(defaultValue = "0") int page,
        @Parameter(example = "20") @Min(1) @Max(100)
        @RequestParam(defaultValue = "20") int size
    ) {
        return service.list(status, PageRequest.of(page, size))
            .map(ProjectDto::from);
    }

    @Operation(summary = "Get project by ID")
    @GetMapping("/{id}")
    public ProjectDto get(@PathVariable Long id) {
        return ProjectDto.from(service.findById(id));
    }

    @Operation(summary = "Update project")
    @PutMapping("/{id}")
    public ProjectDto update(@PathVariable Long id,
                              @Valid @RequestBody UpdateProjectRequest req) {
        return ProjectDto.from(service.update(id, req));
    }

    @Operation(summary = "Delete project (cascade tasks)")
    @ApiResponse(responseCode = "204", description = "Deleted successfully")
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

Exception Handler — GlobalExceptionHandler.java:

@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {

    @ExceptionHandler(ProjectNotFoundException.class)
    public ProblemDetail handleProjectNotFound(ProjectNotFoundException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        pd.setTitle("Project not found");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("projectId", ex.getProjectId());
        return pd;
    }

    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleTaskNotFound(TaskNotFoundException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.NOT_FOUND);
        pd.setTitle("Task not found");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("taskId", ex.getTaskId());
        return pd;
    }

    @ExceptionHandler(DuplicateProjectNameException.class)
    public ProblemDetail handleDuplicate(DuplicateProjectNameException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        pd.setTitle("Project name already exists");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        return pd;
    }

    @ExceptionHandler(InvalidStateTransitionException.class)
    public ProblemDetail handleInvalidTransition(InvalidStateTransitionException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
        pd.setTitle("Invalid state transition");
        pd.setDetail(ex.getMessage());
        pd.setInstance(URI.create(req.getRequestURI()));
        return pd;
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("Request has " + ex.getBindingResult().getErrorCount() + " invalid field(s)");
        pd.setInstance(URI.create(req.getRequestURI()));

        List<Map<String, Object>> violations = ex.getBindingResult().getFieldErrors().stream()
            .map(err -> Map.<String, Object>of(
                "field", err.getField(),
                "message", err.getDefaultMessage(),
                "rejectedValue", String.valueOf(err.getRejectedValue())
            ))
            .toList();
        pd.setProperty("violations", violations);

        return pd;
    }

    @ExceptionHandler(ConstraintViolationException.class)
    public ProblemDetail handleConstraintViolation(ConstraintViolationException ex, HttpServletRequest req) {
        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
        pd.setTitle("Validation failed");
        pd.setDetail("Request parameter validation failed");
        pd.setInstance(URI.create(req.getRequestURI()));

        List<Map<String, Object>> violations = ex.getConstraintViolations().stream()
            .map(v -> Map.<String, Object>of(
                "path", v.getPropertyPath().toString(),
                "message", v.getMessage(),
                "rejectedValue", String.valueOf(v.getInvalidValue())
            ))
            .toList();
        pd.setProperty("violations", violations);

        return pd;
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleAll(Exception ex, HttpServletRequest req) {
        String requestId = MDC.get("requestId");
        log.error("Unhandled exception, requestId={}, path={}", requestId, req.getRequestURI(), ex);

        ProblemDetail pd = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
        pd.setTitle("Internal server error");
        pd.setDetail("An unexpected error occurred. Contact support with request ID.");
        pd.setInstance(URI.create(req.getRequestURI()));
        pd.setProperty("requestId", requestId);
        return pd;
    }
}

Filter — LoggingFilter.java:

@Component
@Slf4j
public class LoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        String requestId = req.getHeader("X-Request-Id");
        if (requestId == null) {
            requestId = UUID.randomUUID().toString().substring(0, 8);
        }

        long start = System.currentTimeMillis();

        try (MDC.MDCCloseable mdc = MDC.putCloseable("requestId", requestId)) {
            log.info(">>> {} {}", req.getMethod(), req.getRequestURI());
            res.setHeader("X-Request-Id", requestId);

            chain.doFilter(req, res);

            long elapsed = System.currentTimeMillis() - start;
            log.info("<<< {} in {}ms", res.getStatus(), elapsed);
        }
    }
}

application.yml:

server:
  port: 8080
  shutdown: graceful

spring:
  application:
    name: taskflow-api
  mvc:
    problemdetails:
      enabled: true
  lifecycle:
    timeout-per-shutdown-phase: 30s

logging:
  level:
    com.olhub.taskflow: DEBUG
  pattern:
    console: "%d{HH:mm:ss.SSS} [%thread] %-5level [%X{requestId:-no-req}] %logger{30} : %msg%n"

springdoc:
  api-docs:
    path: /v3/api-docs
  swagger-ui:
    path: /swagger-ui.html
    try-it-out-enabled: true

management:
  endpoints:
    web:
      exposure:
        include: health, info, conditions, mappings, loggers

✅ Test workflow

✅ Sau khi build xong, chạy + test

Run:

mvn spring-boot:run

Open Swagger UI:

http://localhost:8080/swagger-ui.html

Test endpoint qua "Try it out".

Curl mẫu:

# 1. Create project
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile Redesign","description":"v3 redesign","status":"PLANNING"}'
# 201 Created
# Location: /api/v1/projects/1

# 2. List projects
curl http://localhost:8080/api/v1/projects?status=PLANNING

# 3. Get project
curl http://localhost:8080/api/v1/projects/1

# 4. Create task
curl -X POST http://localhost:8080/api/v1/projects/1/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Design home screen","priority":"HIGH","dueDate":"2026-05-01"}'
# 201 Created

# 5. Validation error
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"X"}'
# 400 Bad Request, Problem Details với violations

# 6. Not found
curl http://localhost:8080/api/v1/projects/999
# 404 Not Found, Problem Details

# 7. Conflict
curl -X POST http://localhost:8080/api/v1/projects \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile Redesign"}'
# 409 Conflict (duplicate name)

# 8. Delete
curl -X DELETE http://localhost:8080/api/v1/projects/1
# 204 No Content

Expected Problem Details cho 400:

{
  "type": "about:blank",
  "title": "Validation failed",
  "status": 400,
  "detail": "Request has 1 invalid field(s)",
  "instance": "/api/v1/projects",
  "violations": [
    {"field": "name", "message": "size must be between 3 and 100", "rejectedValue": "X"}
  ]
}

Expected log output:

10:00:00.123 [http-nio-8080-exec-1] INFO  [a1b2c3d4] LoggingFilter : >>> POST /api/v1/projects
10:00:00.130 [http-nio-8080-exec-1] INFO  [a1b2c3d4] ProjectService : Created project 1
10:00:00.135 [http-nio-8080-exec-1] INFO  [a1b2c3d4] LoggingFilter : <<< 201 in 12ms

🎓 Mở rộng

Mức 1 — Test slice với MockMvc:

@WebMvcTest(ProjectController.class)
class ProjectControllerTest {

    @Autowired MockMvc mockMvc;
    @MockitoBean ProjectService service;
    @Autowired ObjectMapper json;

    @Test
    void create_validRequest_returns201WithLocation() throws Exception {
        Project created = new Project(1L, "Mobile App", null, ProjectStatus.PLANNING, Instant.now());
        when(service.create(any())).thenReturn(created);

        mockMvc.perform(post("/api/v1/projects")
                .contentType(MediaType.APPLICATION_JSON)
                .content(json.writeValueAsString(new CreateProjectRequest("Mobile App", null, null))))
            .andExpect(status().isCreated())
            .andExpect(header().string("Location", endsWith("/api/v1/projects/1")))
            .andExpect(jsonPath("$.id").value(1))
            .andExpect(jsonPath("$.name").value("Mobile App"));
    }

    @Test
    void create_invalidName_returns400() throws Exception {
        mockMvc.perform(post("/api/v1/projects")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"name\":\"\"}"))
            .andExpect(status().isBadRequest())
            .andExpect(jsonPath("$.title").value("Validation failed"))
            .andExpect(jsonPath("$.violations[0].field").value("name"));
    }

    @Test
    void getNonExistent_returns404() throws Exception {
        when(service.findById(99L)).thenThrow(new ProjectNotFoundException(99L));

        mockMvc.perform(get("/api/v1/projects/99"))
            .andExpect(status().isNotFound())
            .andExpect(jsonPath("$.title").value("Project not found"))
            .andExpect(jsonPath("$.projectId").value(99));
    }
}

Mức 2 — State transition validation:

Project chuyển status theo rule: PLANNING → ACTIVE → DONE → ARCHIVED. Không skip, không reverse:

public Project update(Long id, UpdateProjectRequest req) {
    Project existing = findById(id);
    validateStateTransition(existing.status(), req.status());
    // ...
}

private void validateStateTransition(ProjectStatus from, ProjectStatus to) {
    if (from == to) return;
    Map<ProjectStatus, Set<ProjectStatus>> allowed = Map.of(
        ProjectStatus.PLANNING, Set.of(ProjectStatus.ACTIVE, ProjectStatus.ARCHIVED),
        ProjectStatus.ACTIVE, Set.of(ProjectStatus.DONE, ProjectStatus.ARCHIVED),
        ProjectStatus.DONE, Set.of(ProjectStatus.ARCHIVED),
        ProjectStatus.ARCHIVED, Set.of()
    );
    if (!allowed.getOrDefault(from, Set.of()).contains(to)) {
        throw new InvalidStateTransitionException(from, to);
    }
}

Test:

curl -X PUT http://localhost:8080/api/v1/projects/1 \
  -H "Content-Type: application/json" \
  -d '{"name":"Mobile","status":"DONE"}'  # PLANNING → DONE skip
# 422 Unprocessable Entity

Mức 3 — Pagination với metadata header:

@GetMapping
public ResponseEntity<List<ProjectDto>> list(...) {
    Page<ProjectDto> page = service.list(status, PageRequest.of(pageNum, size)).map(ProjectDto::from);

    return ResponseEntity.ok()
        .header("X-Total-Count", String.valueOf(page.getTotalElements()))
        .header("X-Total-Pages", String.valueOf(page.getTotalPages()))
        .header("X-Page-Number", String.valueOf(page.getNumber()))
        .body(page.getContent());
}

Mức 4 — ETag cho conditional GET:

@GetMapping("/{id}")
public ResponseEntity<ProjectDto> get(
    @PathVariable Long id,
    @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
    Project project = service.findById(id);
    String etag = "\"" + project.id() + "-" + project.createdAt().toEpochMilli() + "\"";

    if (etag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
    }

    return ResponseEntity.ok()
        .eTag(etag)
        .body(ProjectDto.from(project));
}

Mức 5 — JSON Patch RFC 6902:

curl -X PATCH http://localhost:8080/api/v1/projects/1/tasks/5 \
  -H "Content-Type: application/json-patch+json" \
  -d '[
    { "op": "replace", "path": "/status", "value": "DONE" },
    { "op": "replace", "path": "/priority", "value": "LOW" }
  ]'

Lib: com.github.java-json-tools:json-patch. Apply patch document → entity → save.

✨ Điều bạn vừa làm được

Hoàn thành mini-challenge này, bạn đã:

  • Build production-grade Spring Boot REST API: 8 endpoint, 3 layer architecture (Controller / Service / Repository), separation of concerns rõ ràng.
  • Apply tất cả 7 concept phần REST API: DispatcherServlet (auto), @RestController, request binding, response handling, exception handling RFC 9457, validation Jakarta, OpenAPI doc.
  • Layered architecture testable: Repository interface cho phép phần JPA swap in-memory → JPA mà không sửa Service.
  • Production patterns: Problem Details RFC 9457 cho error, MDC correlation ID, Swagger UI dev workflow, status code chuẩn (201/204/404/409/422/400).
  • Domain-driven exception handling: Domain exception (ProjectNotFoundException) pure POJO, mapping HTTP qua @RestControllerAdvice — separation clean.
  • Testable code: separation cho phép MockMvc test controller, JUnit test service với mock repo, no Spring context needed cho unit test.

App này là starter template cho mọi phần sau: phần JPA thêm @Entity lên Project/Task và swap InMemoryProjectRepository sang JpaRepository (Service layer không đổi); course Security thêm JWT + role-based access; module Testing thêm Testcontainers với real Postgres.

Tự kiểm tra

Tự kiểm tra
Q1
Vì sao đề bài bắt Repository là interface + in-memory impl thay vì viết thẳng ConcurrentHashMap trong Service? Lợi ích cụ thể khi sang phần JPA là gì?

Interface tách contract (Service cần lưu/tìm Project) khỏi implementation (lưu ở đâu, bằng gì). Service chỉ phụ thuộc interface — không biết và không quan tâm data nằm trong map hay Postgres.

Khi sang phần JPA (TaskFlow v2), bạn chỉ cần thay impl: InMemoryProjectRepository nghỉ việc, ProjectRepository extends JpaRepository vào thay. Service, Controller, DTO, exception handling — toàn bộ không sửa một dòng. Nếu Service ôm thẳng ConcurrentHashMap, migration nghĩa là viết lại Service + sửa toàn bộ test của nó.

Q2
Domain exception như ProjectNotFoundException được yêu cầu là POJO thuần — không import gì từ org.springframework.http. Thiết kế này mua được gì?

Domain exception nói bằng ngôn ngữ business ("project không tồn tại"), không phải ngôn ngữ HTTP ("404"). Việc map sang status code là quyết định của tầng web, tập trung một chỗ trong @RestControllerAdvice.

Lợi ích: (1) service test được ngoài Spring context — không cần MockMvc chỉ để assert exception; (2) cùng domain exception tái dùng được nếu sau này expose qua gRPC/messaging với error mapping khác; (3) audit "exception nào ra status nào" chỉ cần đọc một file advice.

Q3
Vì sao POST trả 201 kèm header Location thay vì 200 kèm body chứa id? Client chuẩn dùng Location như thế nào?

201 Created là semantics HTTP chuẩn cho "resource mới đã được tạo", và Location header cho client biết URL canonical của resource đó — client không cần tự build URL từ id (tránh hard-code URL structure).

Client chuẩn đọc Location rồi GET thẳng URL đó khi cần resource đầy đủ. Đây cũng là nền cho HATEOAS sau này: server làm chủ URL space, client chỉ follow link server đưa.

Q4
Validation lỗi trả 400 với violations, conflict tên project trả 409, project không tồn tại trả 404. Nếu gộp cả ba thành 400 "Bad Request" chung thì client mất khả năng gì?

Status code là contract máy-đọc-được. Client phân nhánh hành vi theo code: 400 + violations nghĩa là highlight field sai trên form; 409 nghĩa là gợi ý user đổi tên (retry với data khác sẽ thành công); 404 nghĩa là resource biến mất — quay về list, đừng retry.

Gộp tất cả thành 400, client buộc phải parse message text để đoán nguyên nhân — message đổi là client gãy. Đây chính là lý do RFC 9457 chuẩn hóa type/status/detail: phân loại lỗi nằm ở field cấu trúc, không nằm trong câu văn.

Chúc mừng — bạn đã hoàn thành phần REST API! TaskFlow REST API v1 đã ready. Phần JPA tiếp theo: thay in-memory bằng Spring Data JPA + PostgreSQL + transactions — tăng app từ "demo" thành "production data persistence".

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên