Skip to main content

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​

  1. Use DTOs: Never expose entities directly
  2. Validate Input: Use @Valid and constraint annotations
  3. Consistent Responses: Use standard response wrappers
  4. Proper HTTP Status: Use appropriate status codes
  5. API Versioning: Include version in URL (/api/v1/)
  6. Security First: Apply @PreAuthorize on sensitive endpoints
  7. Document APIs: Use OpenAPI/Swagger annotations
  8. Handle Errors: Implement global exception handling
  9. Pagination: Always paginate list endpoints
  10. Idempotency: Ensure PUT/DELETE are idempotent

Additional Resources​


Next Steps: Learn about Service Layer Architecture to implement business logic.