REST APIs
Build a complete Task Management API with Spring Boot
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:
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:
| Method | Purpose | Example |
|---|---|---|
GET | Retrieve data | Get all tasks |
POST | Create new data | Create a new task |
PUT | Update existing data | Update task title |
DELETE | Remove data | Delete a task |
HTTP Status Codes tell the client what happened:
| Code | Meaning | When to use |
|---|---|---|
200 OK | Success | Request worked |
201 Created | Created | New resource created |
400 Bad Request | Client error | Invalid data sent |
404 Not Found | Not found | Resource doesn't exist |
500 Server Error | Server error | Something 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>
- 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);
}
}
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
}
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);
}
Spring Data JPA creates SQL queries from method names:
findByStatus→SELECT * FROM tasks WHERE status = ?findByTitleContainingIgnoreCase→SELECT * FROM tasks WHERE LOWER(title) LIKE LOWER('%?%')
No SQL needed! Just name the method correctly.
Part 3: Create DTOs (Data Transfer Objects)
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
| Annotation | Purpose |
|---|---|
@RestController | Combines @Controller + @ResponseBody (returns JSON) |
@RequestMapping | Base URL path for all methods in class |
@GetMapping | Handles HTTP GET requests |
@PostMapping | Handles HTTP POST requests |
@PutMapping | Handles HTTP PUT requests |
@DeleteMapping | Handles HTTP DELETE requests |
@PathVariable | Extracts value from URL path |
@RequestBody | Converts JSON body to Java object |
@Valid | Triggers 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);
}
}
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
Add a GET /api/tasks/search?q=keyword endpoint that searches in both title and description.
Add a dueDate field to Task. Create an endpoint to find overdue tasks.
Add POST /api/tasks/bulk to create multiple tasks at once, and DELETE /api/tasks/bulk to delete multiple by IDs.