REST Controller Guide
Overview
This comprehensive guide covers REST controller development in the Ink platform using Spring Boot. Learn best practices for endpoint design, request handling, response formatting, validation, and error handling.
Target Audience: Backend developers
Prerequisites: Spring Boot and REST API knowledge
Estimated Time: 40-50 minutes
Prerequisites
- Spring Boot 3.x knowledge
- REST API design principles
- HTTP protocol understanding
- JSON serialization concepts
Controller Architecture
Installation Steps
1. Controller Dependencies
<!-- filepath: /Users/jetstart/dev/jetrev/ink/pom.xml -->
<!-- ...existing code... -->
<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.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
</dependencies>
<!-- ...existing code... -->
2. Base Controller Class
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/controller/BaseController.java
@RestController
public abstract class BaseController {
protected <T> ResponseEntity<ApiResponse<T>> success(T data) {
return ResponseEntity.ok(ApiResponse.success(data));
}
protected <T> ResponseEntity<ApiResponse<T>> created(T data) {
return ResponseEntity.status(HttpStatus.CREATED)
.body(ApiResponse.success(data));
}
protected ResponseEntity<Void> noContent() {
return ResponseEntity.noContent().build();
}
protected <T> ResponseEntity<PageResponse<T>> page(Page<T> page) {
return ResponseEntity.ok(PageResponse.from(page));
}
}
3. Standard Response Wrapper
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/dto/ApiResponse.java
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ApiResponse<T> {
private boolean success;
private T data;
private String message;
private Instant timestamp;
public static <T> ApiResponse<T> success(T data) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.timestamp(Instant.now())
.build();
}
public static <T> ApiResponse<T> success(T data, String message) {
return ApiResponse.<T>builder()
.success(true)
.data(data)
.message(message)
.timestamp(Instant.now())
.build();
}
public static <T> ApiResponse<T> error(String message) {
return ApiResponse.<T>builder()
.success(false)
.message(message)
.timestamp(Instant.now())
.build();
}
}
Configuration
Complete CRUD Controller Example
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/controller/UserController.java
@RestController
@RequestMapping("/api/v1/users")
@RequiredArgsConstructor
@Validated
@Tag(name = "Users", description = "User management endpoints")
public class UserController extends BaseController {
private final UserService userService;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get all users", description = "Retrieve paginated list of users")
public ResponseEntity<PageResponse<UserDto>> getAllUsers(
@RequestParam(defaultValue = "0")
@Min(0) int page,
@RequestParam(defaultValue = "20")
@Min(1) @Max(100) int size,
@RequestParam(defaultValue = "id")
String sortBy,
@RequestParam(defaultValue = "ASC")
Sort.Direction direction) {
Page<UserDto> users = userService.getAllUsers(
PageRequest.of(page, size, Sort.by(direction, sortBy)));
return page(users);
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
@Operation(summary = "Get user by ID")
public ResponseEntity<ApiResponse<UserDto>> getUserById(
@PathVariable
@Positive(message = "User ID must be positive")
Long id) {
UserDto user = userService.getUserById(id);
return success(user);
}
@GetMapping("/username/{username}")
@PreAuthorize("isAuthenticated()")
@Operation(summary = "Get user by username")
public ResponseEntity<ApiResponse<UserDto>> getUserByUsername(
@PathVariable
@NotBlank
@Size(min = 3, max = 50)
String username) {
UserDto user = userService.getUserByUsername(username);
return success(user);
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create new user")
public ResponseEntity<ApiResponse<UserDto>> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserDto created = userService.createUser(request);
return created(created);
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
@Operation(summary = "Update user")
public ResponseEntity<ApiResponse<UserDto>> updateUser(
@PathVariable Long id,
@Valid @RequestBody UpdateUserRequest request) {
UserDto updated = userService.updateUser(id, request);
return success(updated);
}
@PatchMapping("/{id}")
@PreAuthorize("hasRole('ADMIN') or @userSecurity.isOwner(#id)")
@Operation(summary = "Partially update user")
public ResponseEntity<ApiResponse<UserDto>> patchUser(
@PathVariable Long id,
@RequestBody Map<String, Object> updates) {
UserDto updated = userService.patchUser(id, updates);
return success(updated);
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Delete user")
public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
userService.deleteUser(id);
return noContent();
}
@PostMapping("/{id}/activate")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Activate user account")
public ResponseEntity<ApiResponse<UserDto>> activateUser(
@PathVariable Long id) {
UserDto activated = userService.activateUser(id);
return success(activated, "User activated successfully");
}
@PostMapping("/{id}/deactivate")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Deactivate user account")
public ResponseEntity<ApiResponse<UserDto>> deactivateUser(
@PathVariable Long id) {
UserDto deactivated = userService.deactivateUser(id);
return success(deactivated, "User deactivated successfully");
}
}
Request DTOs with Validation
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/dto/CreateUserRequest.java
@Data
@Builder
public class CreateUserRequest {
@NotBlank(message = "Username is required")
@Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters")
@Pattern(regexp = "^[a-zA-Z0-9_-]+$",
message = "Username can only contain alphanumeric characters, hyphens, and underscores")
private String username;
@NotBlank(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Password is required")
@Size(min = 8, message = "Password must be at least 8 characters")
@Pattern(
regexp = "^(?=.*[a-z])(?=.*[A-Z])(?=.*\\d)(?=.*[@$!%*?&])[A-Za-z\\d@$!%*?&]+$",
message = "Password must contain uppercase, lowercase, number, and special character"
)
private String password;
@NotNull(message = "First name is required")
private String firstName;
@NotNull(message = "Last name is required")
private String lastName;
@NotNull(message = "Roles are required")
@Size(min = 1, message = "At least one role is required")
private List<@NotBlank String> roles;
}
Custom Validators
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/validation/UniqueUsername.java
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = UniqueUsernameValidator.class)
public @interface UniqueUsername {
String message() default "Username already exists";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/validation/UniqueUsernameValidator.java
@Component
@RequiredArgsConstructor
public class UniqueUsernameValidator
implements ConstraintValidator<UniqueUsername, String> {
private final UserRepository userRepository;
@Override
public boolean isValid(String username, ConstraintValidatorContext context) {
if (username == null) {
return true;
}
return !userRepository.existsByUsername(username);
}
}
Usage Examples
Query Parameters
@GetMapping("/search")
@Operation(summary = "Search users")
public ResponseEntity<PageResponse<UserDto>> searchUsers(
@RequestParam(required = false) String username,
@RequestParam(required = false) String email,
@RequestParam(required = false) Set<String> roles,
@RequestParam(required = false) Boolean active,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
UserSearchCriteria criteria = UserSearchCriteria.builder()
.username(username)
.email(email)
.roles(roles)
.active(active)
.build();
Page<UserDto> results = userService.searchUsers(
criteria, PageRequest.of(page, size));
return page(results);
}
File Upload
@PostMapping("/{id}/avatar")
@PreAuthorize("@userSecurity.isOwner(#id)")
@Operation(summary = "Upload user avatar")
public ResponseEntity<ApiResponse<String>> uploadAvatar(
@PathVariable Long id,
@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
throw new InvalidFileException("File cannot be empty");
}
if (file.getSize() > 5 * 1024 * 1024) { // 5MB
throw new InvalidFileException("File size cannot exceed 5MB");
}
String contentType = file.getContentType();
if (!Arrays.asList("image/jpeg", "image/png").contains(contentType)) {
throw new InvalidFileException("Only JPEG and PNG images are allowed");
}
String avatarUrl = userService.uploadAvatar(id, file);
return success(avatarUrl, "Avatar uploaded successfully");
}
Request Headers
@GetMapping("/me")
@Operation(summary = "Get current user")
public ResponseEntity<ApiResponse<UserDto>> getCurrentUser(
@RequestHeader("Authorization") String authHeader,
@RequestHeader(value = "X-Request-ID", required = false) String requestId) {
UserDto currentUser = userService.getCurrentUser();
if (requestId != null) {
log.info("Request ID: {}", requestId);
}
return success(currentUser);
}
Verification
Controller Testing
// filepath: /Users/jetstart/dev/jetrev/ink/src/test/java/com/jetrev/ink/controller/UserControllerTest.java
@WebMvcTest(UserController.class)
@Import(SecurityConfig.class)
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private UserService userService;
@MockBean
private UserSecurity userSecurity;
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("GET /api/v1/users - should return paginated users")
void testGetAllUsers() throws Exception {
// Given
Page<UserDto> page = new PageImpl<>(Arrays.asList(
UserDto.builder().id(1L).username("user1").build(),
UserDto.builder().id(2L).username("user2").build()
));
when(userService.getAllUsers(any(Pageable.class)))
.thenReturn(page);
// When & Then
mockMvc.perform(get("/api/v1/users")
.param("page", "0")
.param("size", "20"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").isArray())
.andExpect(jsonPath("$.content.length()").value(2))
.andExpect(jsonPath("$.totalElements").value(2));
}
@Test
@WithMockUser(roles = "ADMIN")
@DisplayName("POST /api/v1/users - should create user")
void testCreateUser() throws Exception {
// Given
CreateUserRequest request = CreateUserRequest.builder()
.username("newuser")
.email("new@example.com")
.password("Password123!")
.firstName("New")
.lastName("User")
.roles(List.of("USER"))
.build();
UserDto created = UserDto.builder()
.id(1L)
.username("newuser")
.email("new@example.com")
.build();
when(userService.createUser(any(CreateUserRequest.class)))
.thenReturn(created);
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.id").value(1))
.andExpect(jsonPath("$.data.username").value("newuser"));
}
@Test
@WithMockUser(roles = "USER")
@DisplayName("POST /api/v1/users - should return 403 for non-admin")
void testCreateUserForbidden() throws Exception {
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser
@DisplayName("POST /api/v1/users - should validate request")
void testCreateUserValidation() throws Exception {
// Given - invalid request
CreateUserRequest request = CreateUserRequest.builder()
.username("ab") // Too short
.email("invalid-email")
.password("weak")
.build();
// When & Then
mockMvc.perform(post("/api/v1/users")
.contentType(MediaType.APPLICATION_JSON)
.content(new ObjectMapper().writeValueAsString(request)))
.andExpect(status().isBadRequest())
.andExpect(jsonPath("$.errors").exists());
}
}
Troubleshooting
CORS Issues
// filepath: /Users/jetstart/dev/jetrev/ink/src/main/java/com/jetrev/ink/config/WebConfig.java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000", "http://localhost:8081")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
Request Body Not Binding
// Ensure @RequestBody is used
@PostMapping
public ResponseEntity<UserDto> createUser(
@RequestBody CreateUserRequest request) { // Add @RequestBody
// ...existing code...
}
// Check Content-Type header
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON) // Required
.content(json));
Code Examples
Async Controller
@GetMapping("/report")
@Operation(summary = "Generate user report")
public CompletableFuture<ResponseEntity<ApiResponse<ReportDto>>> generateReport() {
return userService.generateReportAsync()
.thenApply(this::success);
}
Response Headers
@GetMapping("/{id}/download")
public ResponseEntity<byte[]> downloadUserData(@PathVariable Long id) {
byte[] data = userService.exportUserData(id);
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
headers.setContentDisposition(
ContentDisposition.attachment()
.filename("user-" + id + ".json")
.build());
return ResponseEntity.ok()
.headers(headers)
.body(data);
}
Best Practices
- Use DTOs: Never expose entities directly
- Validate Input: Use
@Validand constraint annotations - Consistent Responses: Use standard response wrappers
- Proper HTTP Status: Use appropriate status codes
- API Versioning: Include version in URL (
/api/v1/) - Security First: Apply
@PreAuthorizeon sensitive endpoints - Document APIs: Use OpenAPI/Swagger annotations
- Handle Errors: Implement global exception handling
- Pagination: Always paginate list endpoints
- Idempotency: Ensure PUT/DELETE are idempotent
Related Documentation
Additional Resources
Next Steps: Learn about Service Layer Architecture to implement business logic.