Spring MVC REST
Master Spring MVC REST APIs with visual diagrams covering URI design, annotations, request mapping, content negotiation, and complete implementation examples
Spring MVC REST
Spring MVC provides powerful support for building RESTful web services. Understanding URI conventions, request mapping, and Spring annotations is essential for creating robust REST APIs.
RESTful URI Structure
flowchart LR
URI["https://api.example.com:8080/accounting/v1/forecasting/accounts/123/transactions"]
URI --> P["Protocol<br/>https://"]
URI --> D["Domain<br/>api.example.com"]
URI --> PORT["Port<br/>8080"]
URI --> APP["Application<br/>accounting"]
URI --> V["Version<br/>v1"]
URI --> DC["Domain Context<br/>forecasting"]
URI --> R["Resource<br/>accounts"]
URI --> ID["Resource ID<br/>123"]
URI --> SR["Sub Resource<br/>transactions"]
style R fill:#4CAF50
style SR fill:#FF9800
Key Points:
- Protocol: HTTPS for secure communication
- Versioning: Include API version (/v1, /v2)
- Domain Context: Business domain grouping
- Resources: Use plural nouns for collections
- Hierarchy: Organize resources hierarchically
URI Design Examples
// Base URL Pattern
// https://api.example.com:8080/app-name/v1/domain/resources
// Collection Resource (Plural)
GET /api/v1/accounting/accounts
// Returns: List of all accounts
// Single Resource (Singular with ID)
GET /api/v1/accounting/accounts/123
// Returns: Single account with ID 123
// Sub-Resource Collection
GET /api/v1/accounting/accounts/123/transactions
// Returns: All transactions for account 123
// Single Sub-Resource
GET /api/v1/accounting/accounts/123/transactions/567
// Returns: Transaction 567 for account 123
// Search/Filter with Query Parameters
GET /api/v1/accounting/accounts/123/transactions?date=2026-01-01&status=COMPLETED
// Returns: Filtered transactions
// Pagination
GET /api/v1/accounting/accounts?page=0&size=20&sort=name,asc
// Returns: Paginated accounts
Spring MVC Request Mapping
sequenceDiagram
participant Client
participant DispatcherServlet
participant HandlerMapping
participant Controller
participant Service
Client->>DispatcherServlet: HTTP Request
DispatcherServlet->>HandlerMapping: Find Handler
HandlerMapping->>DispatcherServlet: Return Controller Method
DispatcherServlet->>Controller: Invoke Method
Controller->>Service: Business Logic
Service->>Controller: Result
Controller->>DispatcherServlet: ModelAndView/ResponseBody
DispatcherServlet->>Client: HTTP Response
Note over Controller: @RequestMapping<br/>@PathVariable<br/>@RequestParam
Key Points:
- DispatcherServlet: Front controller handling all requests
- HandlerMapping: Maps URLs to controller methods
- @RequestMapping: Maps HTTP requests to handler methods
- @PathVariable: Extracts URI template variables
- @RequestParam: Extracts query parameters
Complete Spring MVC REST Controller
@RestController
@RequestMapping("/api/v1/accounting")
@Validated
public class AccountController {
@Autowired
private AccountService accountService;
// GET - All accounts (Collection)
@GetMapping("/accounts")
public ResponseEntity<Page<Account>> getAllAccounts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id") String sortBy) {
Pageable pageable = PageRequest.of(page, size,
Sort.by(sortBy).ascending());
Page<Account> accounts = accountService.findAll(pageable);
return ResponseEntity.ok(accounts);
}
// GET - Single account by ID
@GetMapping("/accounts/{accountId}")
public ResponseEntity<Account> getAccount(
@PathVariable Long accountId) {
return accountService.findById(accountId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// GET - Account with special header
@GetMapping(value = "/accounts/{accountId}",
headers = "X-Operation=special")
public ResponseEntity<Account> getAccountSpecial(
@PathVariable Long accountId,
@RequestHeader("X-Operation") String operation) {
Account account = accountService.findByIdWithSpecialHandling(accountId);
return ResponseEntity.ok(account);
}
// GET - Transactions for account
@GetMapping("/accounts/{accountId}/transactions")
public ResponseEntity<List<Transaction>> getAccountTransactions(
@PathVariable Long accountId) {
List<Transaction> transactions =
accountService.findTransactions(accountId);
return ResponseEntity.ok(transactions);
}
// GET - Single transaction
@GetMapping("/accounts/{accountId}/transactions/{txnId}")
public ResponseEntity<Transaction> getTransaction(
@PathVariable Long accountId,
@PathVariable Long txnId) {
return accountService.findTransaction(accountId, txnId)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// GET - Search transactions with query parameters
@GetMapping("/accounts/{accountId}/transactions/search")
public ResponseEntity<List<Transaction>> searchTransactions(
@PathVariable Long accountId,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date,
@RequestParam(required = false) String status) {
List<Transaction> transactions =
accountService.searchTransactions(accountId, date, status);
return ResponseEntity.ok(transactions);
}
// POST - Create new account
@PostMapping("/accounts")
public ResponseEntity<Account> createAccount(
@Valid @RequestBody AccountRequest request) {
Account account = accountService.create(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(account.getId())
.toUri();
return ResponseEntity.created(location).body(account);
}
// POST - Create transaction
@PostMapping("/accounts/{accountId}/transactions")
public ResponseEntity<Transaction> createTransaction(
@PathVariable Long accountId,
@Valid @RequestBody TransactionRequest request) {
Transaction transaction =
accountService.createTransaction(accountId, request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(transaction);
}
// PUT - Update account (full update)
@PutMapping("/accounts/{accountId}")
public ResponseEntity<Account> updateAccount(
@PathVariable Long accountId,
@Valid @RequestBody AccountRequest request) {
if (!accountService.exists(accountId)) {
return ResponseEntity.notFound().build();
}
Account updated = accountService.update(accountId, request);
return ResponseEntity.ok(updated);
}
// PATCH - Partial update
@PatchMapping("/accounts/{accountId}")
public ResponseEntity<Account> patchAccount(
@PathVariable Long accountId,
@RequestBody Map<String, Object> updates) {
return accountService.findById(accountId)
.map(account -> {
Account patched = accountService.patch(account, updates);
return ResponseEntity.ok(patched);
})
.orElse(ResponseEntity.notFound().build());
}
// DELETE - Remove account
@DeleteMapping("/accounts/{accountId}")
public ResponseEntity<Void> deleteAccount(
@PathVariable Long accountId) {
if (!accountService.exists(accountId)) {
return ResponseEntity.notFound().build();
}
accountService.delete(accountId);
return ResponseEntity.noContent().build();
}
}
Content Negotiation
graph TB
A[Client Request] --> B{Accept Header}
B -->|application/json| C[JSON Response]
B -->|application/xml| D[XML Response]
B -->|text/csv| E[CSV Response]
B -->|application/pdf| F[PDF Response]
C --> G[Jackson Converter]
D --> H[JAXB Converter]
E --> I[Custom Converter]
F --> J[Custom Converter]
style C fill:#4CAF50
style D fill:#FF9800
Key Points:
- Accept Header: Client specifies desired format
- Produces: Server declares supported formats
- Message Converters: Transform objects to/from formats
- Content Type: Response includes Content-Type header
- Multiple Formats: Support JSON, XML, CSV, etc.
Content Negotiation Implementation
@RestController
@RequestMapping("/api/v1/accounts")
public class AccountContentController {
@Autowired
private AccountService accountService;
// JSON Response (default)
@GetMapping(value = "/{id}",
produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Account> getAccountJson(
@PathVariable Long id) {
return accountService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// XML Response
@GetMapping(value = "/{id}",
produces = MediaType.APPLICATION_XML_VALUE)
public ResponseEntity<Account> getAccountXml(
@PathVariable Long id) {
return accountService.findById(id)
.map(ResponseEntity::ok)
.orElse(ResponseEntity.notFound().build());
}
// CSV Download
@GetMapping(value = "/export",
produces = "text/csv")
public void exportAccountsCsv(HttpServletResponse response)
throws IOException {
response.setHeader("Content-Disposition",
"attachment; filename=accounts.csv");
List<Account> accounts = accountService.findAll();
PrintWriter writer = response.getWriter();
writer.println("ID,Name,Balance,Status");
for (Account account : accounts) {
writer.printf("%d,%s,%.2f,%s%n",
account.getId(),
account.getName(),
account.getBalance(),
account.getStatus());
}
}
// Multiple formats with content negotiation
@GetMapping(value = "/{id}",
produces = {
MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE
})
public ResponseEntity<Account> getAccount(
@PathVariable Long id,
@RequestHeader(value = "Accept",
defaultValue = "application/json") String accept) {
return accountService.findById(id)
.map(account -> {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.parseMediaType(accept));
return new ResponseEntity<>(account, headers, HttpStatus.OK);
})
.orElse(ResponseEntity.notFound().build());
}
}
// Configuration for content negotiation
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void configureContentNegotiation(
ContentNegotiationConfigurer configurer) {
configurer
.favorParameter(false)
.ignoreAcceptHeader(false)
.defaultContentType(MediaType.APPLICATION_JSON)
.mediaType("json", MediaType.APPLICATION_JSON)
.mediaType("xml", MediaType.APPLICATION_XML)
.mediaType("csv", MediaType.parseMediaType("text/csv"));
}
@Override
public void configureMessageConverters(
List<HttpMessageConverter<?>> converters) {
// JSON converter
MappingJackson2HttpMessageConverter jsonConverter =
new MappingJackson2HttpMessageConverter();
jsonConverter.setObjectMapper(objectMapper());
converters.add(jsonConverter);
// XML converter
MappingJackson2XmlHttpMessageConverter xmlConverter =
new MappingJackson2XmlHttpMessageConverter();
converters.add(xmlConverter);
}
@Bean
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.registerModule(new JavaTimeModule());
return mapper;
}
}
Exception Handling
@RestControllerAdvice
public class GlobalExceptionHandler {
// Handle resource not found
@ExceptionHandler(ResourceNotFoundException.class)
public ResponseEntity<ErrorResponse> handleNotFound(
ResourceNotFoundException ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
// Handle validation errors
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidation(
MethodArgumentNotValidException ex) {
List<FieldError> fieldErrors = ex.getBindingResult()
.getFieldErrors().stream()
.map(error -> new FieldError(
error.getField(),
error.getDefaultMessage()))
.collect(Collectors.toList());
ValidationErrorResponse response = new ValidationErrorResponse(
HttpStatus.BAD_REQUEST.value(),
"Validation failed",
fieldErrors,
LocalDateTime.now()
);
return ResponseEntity.badRequest().body(response);
}
// Handle generic exceptions
@ExceptionHandler(Exception.class)
public ResponseEntity<ErrorResponse> handleGenericException(
Exception ex) {
ErrorResponse error = new ErrorResponse(
HttpStatus.INTERNAL_SERVER_ERROR.value(),
"An unexpected error occurred",
LocalDateTime.now()
);
return ResponseEntity
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(error);
}
}
@Data
@AllArgsConstructor
class ErrorResponse {
private int status;
private String message;
private LocalDateTime timestamp;
}
@Data
@AllArgsConstructor
class ValidationErrorResponse {
private int status;
private String message;
private List<FieldError> errors;
private LocalDateTime timestamp;
}
REST Best Practices
- Use Nouns, Not Verbs: /accounts not /getAccounts
- Plural for Collections: /accounts not /account
- HTTP Methods for Actions: GET, POST, PUT, DELETE
- Query Parameters for Filtering: ?status=active&page=0
- Versioning: Include version in URL (/v1/accounts)
- Status Codes: Return appropriate HTTP status codes
- No State in URI: Use query params for filtering, not state
- Idempotency: PUT and DELETE should be idempotent