← Back to Advanced Java
Practical Work 4

REST APIs

Build a complete Task Management API with Spring Boot

Duration4-5 hours
DifficultyIntermediate
Session4 - REST APIs

What You Will Learn

REST (Representational State Transfer) is the standard way web applications communicate. In this practical work, you'll build a complete REST API from scratch, learning:

  • HTTP Methods - GET, POST, PUT, DELETE and when to use each
  • Controllers - Java classes that handle incoming requests
  • DTOs - Data Transfer Objects to separate API from internal models
  • Validation - Ensuring data is correct before processing
  • Error Handling - Returning meaningful error messages
  • Testing - Verifying your API works correctly

Prerequisites

  • Java 17+ and Maven installed
  • Basic understanding of HTTP (what a URL is, how browsers work)
  • A REST client (we'll use curl, but Postman works too)

Understanding REST APIs

Before we code, let's understand what we're building:

What is a REST API?

Think of a REST API as a waiter in a restaurant. You (the client) don't go into the kitchen - you tell the waiter what you want. The waiter (API) takes your request to the kitchen (server), and brings back your food (response).

HTTP Methods tell the server what action to perform:

MethodPurposeExample
GETRetrieve dataGet all tasks
POSTCreate new dataCreate a new task
PUTUpdate existing dataUpdate task title
DELETERemove dataDelete a task

HTTP Status Codes tell the client what happened:

CodeMeaningWhen to use
200 OKSuccessRequest worked
201 CreatedCreatedNew resource created
400 Bad RequestClient errorInvalid data sent
404 Not FoundNot foundResource doesn't exist
500 Server ErrorServer errorSomething broke

Part 1: Project Setup

Step 1.1: Create Project Structure

Create a new folder called task-api and create these folders inside:

task-api/
├── pom.xml
└── src/
    └── main/
        ├── java/
        │   └── com/
        │       └── example/
        │           └── taskapi/
        └── resources/
            └── application.properties

Step 1.2: Create pom.xml

The pom.xml file tells Maven what libraries we need. Create this file:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
         http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- (#1:Spring Boot parent provides default configurations) -->
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.3</version>
    </parent>

    <groupId>com.example</groupId>
    <artifactId>task-api</artifactId>
    <version>1.0.0-SNAPSHOT</version>

    <properties>
        <java.version>17</java.version>
    </properties>

    <dependencies>
        <!-- (#2:Web starter - includes embedded Tomcat server) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>

        <!-- (#3:Validation - for checking input data) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- (#4:JPA for database access) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>

        <!-- (#5:H2 in-memory database - no setup needed!) -->
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- (#6:Testing dependencies) -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>
</project>
What are these dependencies?
  • spring-boot-starter-web: Everything needed for REST APIs
  • spring-boot-starter-validation: Input validation annotations
  • spring-boot-starter-data-jpa: Database access
  • h2: A database that runs in memory (no installation needed)

Step 1.3: Create application.properties

Create src/main/resources/application.properties:

# Server configuration
server.port=8080

# H2 Database (in-memory, resets when app restarts)
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driverClassName=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=

# JPA settings
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true

# H2 Console (view database in browser)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

Step 1.4: Create Main Application Class

Create src/main/java/com/example/taskapi/TaskApiApplication.java:

package com.example.taskapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication  // (#1:This annotation does all the magic!)
public class TaskApiApplication {

    public static void main(String[] args) {
        // (#2:Start the Spring Boot application)
        SpringApplication.run(TaskApiApplication.class, args);
    }
}
What does @SpringBootApplication do?

It combines three annotations:

  • @Configuration: This class can define beans
  • @EnableAutoConfiguration: Spring auto-configures based on dependencies
  • @ComponentScan: Spring finds your controllers and services

Step 1.5: Test the Setup

Run the application to make sure everything works:

cd task-api
mvn spring-boot:run

You should see output ending with:

Started TaskApiApplication in X.XXX seconds

Visit http://localhost:8080/h2-console to see the database console. Use JDBC URL: jdbc:h2:mem:taskdb

Part 2: Create the Data Model

Step 2.1: Create the Task Entity

An entity represents a table in the database. Create src/main/java/com/example/taskapi/entity/Task.java:

package com.example.taskapi.entity;

import jakarta.persistence.*;
import java.time.LocalDateTime;

@Entity  // (#1:Tells JPA this class maps to a database table)
@Table(name = "tasks")  // (#2:The table will be named "tasks")
public class Task {

    @Id  // (#3:This field is the primary key)
    @GeneratedValue(strategy = GenerationType.IDENTITY)  // (#4:Auto-increment)
    private Long id;

    @Column(nullable = false)  // (#5:This column cannot be NULL)
    private String title;

    @Column(length = 1000)  // (#6:Allow longer descriptions)
    private String description;

    @Enumerated(EnumType.STRING)  // (#7:Store enum as text, not number)
    private TaskStatus status = TaskStatus.TODO;

    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // (#8:Called automatically before saving new entity)
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    // (#9:Called automatically before updating entity)
    @PreUpdate
    protected void onUpdate() {
        updatedAt = LocalDateTime.now();
    }

    // Default constructor (required by JPA)
    public Task() {}

    // Constructor with fields
    public Task(String title, String description) {
        this.title = title;
        this.description = description;
    }

    // Getters and Setters
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }

    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public TaskStatus getStatus() { return status; }
    public void setStatus(TaskStatus status) { this.status = status; }

    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

Step 2.2: Create the TaskStatus Enum

Create src/main/java/com/example/taskapi/entity/TaskStatus.java:

package com.example.taskapi.entity;

public enum TaskStatus {
    TODO,        // Task not started
    IN_PROGRESS, // Task being worked on
    DONE         // Task completed
}
Why use an Enum?

Enums restrict values to a predefined set. Instead of any string, status can only be TODO, IN_PROGRESS, or DONE. This prevents bugs from typos like "in progress" or "InProgress".

Step 2.3: Create the Repository

A repository handles database operations. Create src/main/java/com/example/taskapi/repository/TaskRepository.java:

package com.example.taskapi.repository;

import com.example.taskapi.entity.Task;
import com.example.taskapi.entity.TaskStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

// (#1:JpaRepository provides CRUD methods automatically!)
// (#2:<Task, Long> means: Entity type is Task, ID type is Long)
public interface TaskRepository extends JpaRepository<Task, Long> {

    // (#3:Spring creates this method from the name!)
    List<Task> findByStatus(TaskStatus status);

    // (#4:Find tasks containing text (case-insensitive))
    List<Task> findByTitleContainingIgnoreCase(String title);
}
Magic Method Names!

Spring Data JPA creates SQL queries from method names:

  • findByStatusSELECT * FROM tasks WHERE status = ?
  • findByTitleContainingIgnoreCaseSELECT * FROM tasks WHERE LOWER(title) LIKE LOWER('%?%')

No SQL needed! Just name the method correctly.

Part 3: Create DTOs (Data Transfer Objects)

Why use DTOs?

DTOs separate your API from your database model:

  • Security: Don't expose internal fields (like createdAt)
  • Flexibility: API can differ from database structure
  • Validation: Validate input before it reaches the entity

Step 3.1: Create Request DTO

This DTO is for incoming data (creating/updating tasks). Create src/main/java/com/example/taskapi/dto/TaskRequest.java:

package com.example.taskapi.dto;

import com.example.taskapi.entity.TaskStatus;
import jakarta.validation.constraints.*;

public class TaskRequest {

    @NotBlank(message = "Title is required")  // (#1:Cannot be null or empty)
    @Size(min = 1, max = 100, message = "Title must be 1-100 characters")
    private String title;

    @Size(max = 1000, message = "Description cannot exceed 1000 characters")
    private String description;

    private TaskStatus status;  // (#2:Optional, defaults to TODO)

    // Getters and Setters
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }

    public String getDescription() { return description; }
    public void setDescription(String description) { this.description = description; }

    public TaskStatus getStatus() { return status; }
    public void setStatus(TaskStatus status) { this.status = status; }
}

Step 3.2: Create Response DTO

This DTO is for outgoing data (what the API returns). Create src/main/java/com/example/taskapi/dto/TaskResponse.java:

package com.example.taskapi.dto;

import com.example.taskapi.entity.Task;
import com.example.taskapi.entity.TaskStatus;
import java.time.LocalDateTime;

public class TaskResponse {

    private Long id;
    private String title;
    private String description;
    private TaskStatus status;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    // (#1:Convert Entity to DTO)
    public static TaskResponse fromEntity(Task task) {
        TaskResponse response = new TaskResponse();
        response.id = task.getId();
        response.title = task.getTitle();
        response.description = task.getDescription();
        response.status = task.getStatus();
        response.createdAt = task.getCreatedAt();
        response.updatedAt = task.getUpdatedAt();
        return response;
    }

    // Getters (no setters needed - this is read-only)
    public Long getId() { return id; }
    public String getTitle() { return title; }
    public String getDescription() { return description; }
    public TaskStatus getStatus() { return status; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
}

Part 4: Create the REST Controller

Step 4.1: Create TaskController

The controller handles HTTP requests. Create src/main/java/com/example/taskapi/controller/TaskController.java:

package com.example.taskapi.controller;

import com.example.taskapi.dto.*;
import com.example.taskapi.entity.*;
import com.example.taskapi.repository.TaskRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestController  // (#1:This class handles REST requests)
@RequestMapping("/api/tasks")  // (#2:All endpoints start with /api/tasks)
public class TaskController {

    private final TaskRepository taskRepository;

    // (#3:Constructor injection - Spring provides the repository)
    public TaskController(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    // ==================== GET (Read) ====================

    // (#4:GET /api/tasks - Get all tasks)
    @GetMapping
    public List<TaskResponse> getAllTasks() {
        return taskRepository.findAll()
                .stream()
                .map(TaskResponse::fromEntity)
                .toList();
    }

    // (#5:GET /api/tasks/{id} - Get one task by ID)
    @GetMapping("/{id}")
    public ResponseEntity<TaskResponse> getTaskById(@PathVariable Long id) {
        return taskRepository.findById(id)
                .map(task -> ResponseEntity.ok(TaskResponse.fromEntity(task)))
                .orElse(ResponseEntity.notFound().build());
    }

    // (#6:GET /api/tasks/status/{status} - Filter by status)
    @GetMapping("/status/{status}")
    public List<TaskResponse> getTasksByStatus(@PathVariable TaskStatus status) {
        return taskRepository.findByStatus(status)
                .stream()
                .map(TaskResponse::fromEntity)
                .toList();
    }

    // ==================== POST (Create) ====================

    // (#7:POST /api/tasks - Create a new task)
    @PostMapping
    public ResponseEntity<TaskResponse> createTask(
            @Valid @RequestBody TaskRequest request) {  // (#8:@Valid triggers validation)

        Task task = new Task();
        task.setTitle(request.getTitle());
        task.setDescription(request.getDescription());
        if (request.getStatus() != null) {
            task.setStatus(request.getStatus());
        }

        Task saved = taskRepository.save(task);

        return ResponseEntity
                .status(HttpStatus.CREATED)  // (#9:Return 201 for creation)
                .body(TaskResponse.fromEntity(saved));
    }

    // ==================== PUT (Update) ====================

    // (#10:PUT /api/tasks/{id} - Update a task)
    @PutMapping("/{id}")
    public ResponseEntity<TaskResponse> updateTask(
            @PathVariable Long id,
            @Valid @RequestBody TaskRequest request) {

        return taskRepository.findById(id)
                .map(task -> {
                    task.setTitle(request.getTitle());
                    task.setDescription(request.getDescription());
                    if (request.getStatus() != null) {
                        task.setStatus(request.getStatus());
                    }
                    Task updated = taskRepository.save(task);
                    return ResponseEntity.ok(TaskResponse.fromEntity(updated));
                })
                .orElse(ResponseEntity.notFound().build());
    }

    // ==================== DELETE ====================

    // (#11:DELETE /api/tasks/{id} - Delete a task)
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
        if (taskRepository.existsById(id)) {
            taskRepository.deleteById(id);
            return ResponseEntity.noContent().build();  // (#12:204 No Content)
        }
        return ResponseEntity.notFound().build();
    }
}

Step 4.2: Understanding the Annotations

AnnotationPurpose
@RestControllerCombines @Controller + @ResponseBody (returns JSON)
@RequestMappingBase URL path for all methods in class
@GetMappingHandles HTTP GET requests
@PostMappingHandles HTTP POST requests
@PutMappingHandles HTTP PUT requests
@DeleteMappingHandles HTTP DELETE requests
@PathVariableExtracts value from URL path
@RequestBodyConverts JSON body to Java object
@ValidTriggers validation on the request body

Part 5: Add Error Handling

Step 5.1: Create Error Response DTO

Create src/main/java/com/example/taskapi/dto/ErrorResponse.java:

package com.example.taskapi.dto;

import java.time.LocalDateTime;
import java.util.List;

public class ErrorResponse {

    private LocalDateTime timestamp;
    private int status;
    private String error;
    private List<String> messages;
    private String path;

    public ErrorResponse(int status, String error, List<String> messages, String path) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.error = error;
        this.messages = messages;
        this.path = path;
    }

    // Convenience constructor for single message
    public ErrorResponse(int status, String error, String message, String path) {
        this(status, error, List.of(message), path);
    }

    // Getters
    public LocalDateTime getTimestamp() { return timestamp; }
    public int getStatus() { return status; }
    public String getError() { return error; }
    public List<String> getMessages() { return messages; }
    public String getPath() { return path; }
}

Step 5.2: Create Global Exception Handler

Create src/main/java/com/example/taskapi/exception/GlobalExceptionHandler.java:

package com.example.taskapi.exception;

import com.example.taskapi.dto.ErrorResponse;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import java.util.List;

@RestControllerAdvice  // (#1:Handles exceptions from all controllers)
public class GlobalExceptionHandler {

    // (#2:Handle validation errors)
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(
            MethodArgumentNotValidException ex,
            HttpServletRequest request) {

        // (#3:Extract all validation error messages)
        List<String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList();

        ErrorResponse response = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Validation Failed",
                errors,
                request.getRequestURI()
        );

        return ResponseEntity.badRequest().body(response);
    }

    // (#4:Handle all other exceptions)
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleAllExceptions(
            Exception ex,
            HttpServletRequest request) {

        ErrorResponse response = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "Internal Server Error",
                ex.getMessage(),
                request.getRequestURI()
        );

        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
    }
}
What is @RestControllerAdvice?

It's a global error handler. When any controller throws an exception, Spring looks here for a matching @ExceptionHandler method. This keeps error handling code out of your controllers.

Part 6: Test Your API

Step 6.1: Start the Application

mvn spring-boot:run

Step 6.2: Test with curl (or Postman)

Create a task:

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn REST APIs", "description": "Complete this practical work"}'

Expected response (201 Created):

{
  "id": 1,
  "title": "Learn REST APIs",
  "description": "Complete this practical work",
  "status": "TODO",
  "createdAt": "2024-01-15T10:30:00",
  "updatedAt": "2024-01-15T10:30:00"
}

Get all tasks:

curl http://localhost:8080/api/tasks

Get task by ID:

curl http://localhost:8080/api/tasks/1

Update a task:

curl -X PUT http://localhost:8080/api/tasks/1 \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn REST APIs", "status": "IN_PROGRESS"}'

Delete a task:

curl -X DELETE http://localhost:8080/api/tasks/1

Test validation (should fail):

curl -X POST http://localhost:8080/api/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": ""}'

Expected response (400 Bad Request):

{
  "timestamp": "2024-01-15T10:35:00",
  "status": 400,
  "error": "Validation Failed",
  "messages": ["title: Title is required"],
  "path": "/api/tasks"
}

Part 7: Write Integration Tests

Step 7.1: Create Test Class

Create src/test/java/com/example/taskapi/controller/TaskControllerTest.java:

package com.example.taskapi.controller;

import com.example.taskapi.entity.*;
import com.example.taskapi.repository.TaskRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest  // (#1:Load full application context)
@AutoConfigureMockMvc  // (#2:Configure MockMvc for testing)
class TaskControllerTest {

    @Autowired
    private MockMvc mockMvc;  // (#3:Simulates HTTP requests)

    @Autowired
    private TaskRepository taskRepository;

    @Autowired
    private ObjectMapper objectMapper;  // (#4:Converts objects to JSON)

    @BeforeEach
    void setUp() {
        taskRepository.deleteAll();  // (#5:Clean database before each test)
    }

    @Test
    @DisplayName("POST /api/tasks - should create a task")
    void createTask_Success() throws Exception {
        String json = """
            {
                "title": "Test Task",
                "description": "Test Description"
            }
            """;

        mockMvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isCreated())  // (#6:Expect 201)
                .andExpect(jsonPath("$.id").exists())
                .andExpect(jsonPath("$.title").value("Test Task"))
                .andExpect(jsonPath("$.status").value("TODO"));
    }

    @Test
    @DisplayName("POST /api/tasks - should fail with empty title")
    void createTask_ValidationError() throws Exception {
        String json = """
            {
                "title": "",
                "description": "Test"
            }
            """;

        mockMvc.perform(post("/api/tasks")
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isBadRequest())  // (#7:Expect 400)
                .andExpect(jsonPath("$.error").value("Validation Failed"));
    }

    @Test
    @DisplayName("GET /api/tasks/{id} - should return task")
    void getTaskById_Success() throws Exception {
        // (#8:Create a task directly in the database)
        Task task = new Task("Existing Task", "Description");
        task = taskRepository.save(task);

        mockMvc.perform(get("/api/tasks/" + task.getId()))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Existing Task"));
    }

    @Test
    @DisplayName("GET /api/tasks/{id} - should return 404 for non-existent task")
    void getTaskById_NotFound() throws Exception {
        mockMvc.perform(get("/api/tasks/999"))
                .andExpect(status().isNotFound());  // (#9:Expect 404)
    }

    @Test
    @DisplayName("PUT /api/tasks/{id} - should update task")
    void updateTask_Success() throws Exception {
        Task task = taskRepository.save(new Task("Original", "Description"));

        String json = """
            {
                "title": "Updated Title",
                "status": "IN_PROGRESS"
            }
            """;

        mockMvc.perform(put("/api/tasks/" + task.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .content(json))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.title").value("Updated Title"))
                .andExpect(jsonPath("$.status").value("IN_PROGRESS"));
    }

    @Test
    @DisplayName("DELETE /api/tasks/{id} - should delete task")
    void deleteTask_Success() throws Exception {
        Task task = taskRepository.save(new Task("To Delete", "Description"));

        mockMvc.perform(delete("/api/tasks/" + task.getId()))
                .andExpect(status().isNoContent());  // (#10:Expect 204)

        // (#11:Verify task is deleted)
        mockMvc.perform(get("/api/tasks/" + task.getId()))
                .andExpect(status().isNotFound());
    }
}

Step 7.2: Run the Tests

mvn test

Expected output:

[INFO] Tests run: 6, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Part 8: Add Pagination

Step 8.1: Add Paginated Endpoint

Add this method to TaskController.java:

import org.springframework.data.domain.*;

// Add to TaskController class:

// GET /api/tasks/page?page=0&size=10&sort=createdAt,desc
@GetMapping("/page")
public Page<TaskResponse> getTasksPaginated(
        @RequestParam(defaultValue = "0") int page,      // (#1:Page number (0-indexed))
        @RequestParam(defaultValue = "10") int size,     // (#2:Items per page)
        @RequestParam(defaultValue = "createdAt") String sortBy,
        @RequestParam(defaultValue = "desc") String direction) {

    Sort sort = direction.equalsIgnoreCase("asc")
            ? Sort.by(sortBy).ascending()
            : Sort.by(sortBy).descending();

    Pageable pageable = PageRequest.of(page, size, sort);

    return taskRepository.findAll(pageable)
            .map(TaskResponse::fromEntity);  // (#3:Convert each entity to DTO)
}

Step 8.2: Test Pagination

# First page, 5 items per page, sorted by title
curl "http://localhost:8080/api/tasks/page?page=0&size=5&sortBy=title&direction=asc"

Response includes pagination metadata:

{
  "content": [...],
  "pageable": {...},
  "totalElements": 25,
  "totalPages": 5,
  "first": true,
  "last": false,
  "number": 0,
  "size": 5
}

Deliverables Checklist

  • Spring Boot application starts without errors
  • POST /api/tasks creates a new task (returns 201)
  • GET /api/tasks returns all tasks
  • GET /api/tasks/{id} returns a specific task
  • PUT /api/tasks/{id} updates a task
  • DELETE /api/tasks/{id} deletes a task (returns 204)
  • Validation errors return 400 with clear messages
  • Non-existent tasks return 404
  • All integration tests pass
  • Pagination endpoint works with sorting

Project Structure Summary

task-api/
├── pom.xml
└── src/
    ├── main/
    │   ├── java/com/example/taskapi/
    │   │   ├── TaskApiApplication.java
    │   │   ├── controller/
    │   │   │   └── TaskController.java
    │   │   ├── dto/
    │   │   │   ├── TaskRequest.java
    │   │   │   ├── TaskResponse.java
    │   │   │   └── ErrorResponse.java
    │   │   ├── entity/
    │   │   │   ├── Task.java
    │   │   │   └── TaskStatus.java
    │   │   ├── exception/
    │   │   │   └── GlobalExceptionHandler.java
    │   │   └── repository/
    │   │       └── TaskRepository.java
    │   └── resources/
    │       └── application.properties
    └── test/
        └── java/com/example/taskapi/
            └── controller/
                └── TaskControllerTest.java

Bonus Challenges

Challenge 1: Add Search

Add a GET /api/tasks/search?q=keyword endpoint that searches in both title and description.

Challenge 2: Add Due Date

Add a dueDate field to Task. Create an endpoint to find overdue tasks.

Challenge 3: Bulk Operations

Add POST /api/tasks/bulk to create multiple tasks at once, and DELETE /api/tasks/bulk to delete multiple by IDs.