Module 4
Building Modern Web Services
This work is licensed under CC BY-NC-SA 4.0
© Way-Up 2025
REST (Representational State Transfer) is an architectural style for distributed systems.
| Feature | REST | SOAP |
|---|---|---|
| Protocol | HTTP | HTTP, SMTP, TCP |
| Message Format | JSON, XML, HTML | XML only |
| Complexity | Simple | Complex |
| Performance | Faster (lightweight) | Slower (verbose) |
| Use Case | Web, mobile, microservices | Enterprise, legacy systems |
| Method | Purpose | Idempotent | Safe |
|---|---|---|---|
| GET | Retrieve resource | Yes | Yes |
| POST | Create resource | No | No |
| PUT | Update/Replace resource | Yes | No |
| PATCH | Partial update | No | No |
| DELETE | Delete resource | Yes | No |
@RestController
@RequestMapping("/api/users")
public class UserController {
@Autowired
private UserService userService;
@GetMapping
public List<User> getAllUsers() {
return userService.findAll();
}
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public User createUser(@RequestBody User user) {
return userService.create(user);
}
@PutMapping("/{id}")
public User updateUser(@PathVariable Long id, @RequestBody User user) {
return userService.update(id, user);
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteUser(@PathVariable Long id) {
userService.delete(id);
}
}
// Path variables
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) { }
// Query parameters
@GetMapping("/users")
public List<User> getUsers(@RequestParam String name,
@RequestParam(defaultValue = "0") int page) { }
// Request body
@PostMapping("/users")
public User createUser(@RequestBody User user) { }
// Request headers
@GetMapping("/users")
public List<User> getUsers(@RequestHeader("Authorization") String token) { }
// Multiple path variables
@GetMapping("/users/{userId}/posts/{postId}")
public Post getPost(@PathVariable Long userId, @PathVariable Long postId) { }
public class UserDTO {
@NotNull(message = "Email is required")
@Email(message = "Email must be valid")
private String email;
@NotBlank(message = "Name is required")
@Size(min = 2, max = 100, message = "Name must be between 2 and 100 characters")
private String name;
@Min(value = 18, message = "Age must be at least 18")
@Max(value = 120, message = "Age must be less than 120")
private Integer age;
@Pattern(regexp = "^\\+?[1-9]\\d{1,14}$", message = "Invalid phone number")
private String phone;
}
@PostMapping("/users")
public User createUser(@Valid @RequestBody UserDTO userDTO) {
return userService.create(userDTO);
}
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(UserNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
public ErrorResponse handleUserNotFound(UserNotFoundException ex) {
return new ErrorResponse(
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
LocalDateTime.now()
);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ErrorResponse handleValidationException(
MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.put(error.getField(),
error.getDefaultMessage()));
return new ErrorResponse(HttpStatus.BAD_REQUEST.value(),
"Validation failed", errors);
}
}
Fine-grained control over HTTP response:
@GetMapping("/{id}")
public ResponseEntity<User> getUserById(@PathVariable Long id) {
return userService.findById(id)
.map(user -> ResponseEntity.ok()
.header("X-Custom-Header", "value")
.body(user))
.orElse(ResponseEntity.notFound().build());
}
@PostMapping
public ResponseEntity<User> createUser(@Valid @RequestBody UserDTO userDTO) {
User created = userService.create(userDTO);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(created.getId())
.toUri();
return ResponseEntity.created(location).body(created);
}
Separate internal models from API contracts:
// Entity - internal model
@Entity
public class User {
private Long id;
private String email;
private String password; // Don't expose!
private String fullName;
}
// DTO - external API
public class UserDTO {
private Long id;
private String email;
private String fullName;
}
// Mapper
@Component
public class UserMapper {
public UserDTO toDTO(User user) {
return new UserDTO(user.getId(), user.getEmail(), user.getFullName());
}
public User toEntity(UserDTO dto) {
User user = new User();
user.setEmail(dto.getEmail());
user.setFullName(dto.getFullName());
return user;
}
}
@GetMapping
public Page<UserDTO> getUsers(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(defaultValue = "id,asc") String[] sort) {
Sort sortOrder = Sort.by(
sort[1].equalsIgnoreCase("desc") ?
Sort.Direction.DESC : Sort.Direction.ASC,
sort[0]
);
Pageable pageable = PageRequest.of(page, size, sortOrder);
return userService.findAll(pageable)
.map(userMapper::toDTO);
}
// Response:
{
"content": [...],
"pageable": {...},
"totalPages": 10,
"totalElements": 200,
"size": 20,
"number": 0
}
@RequestMapping("/api/v1/users")
@RequestMapping("/api/v2/users")
@GetMapping(value = "/api/users", headers = "API-Version=1")
@GetMapping(value = "/api/users", produces = "application/vnd.company.v1+json")
@GetMapping(value = "/api/users", params = "version=1")
Enable Cross-Origin Resource Sharing:
@Configuration
public class CorsConfig {
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOrigins("http://localhost:3000",
"https://example.com")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
};
}
}
Support multiple response formats:
@GetMapping(value = "/users/{id}",
produces = {MediaType.APPLICATION_JSON_VALUE,
MediaType.APPLICATION_XML_VALUE})
public User getUser(@PathVariable Long id) {
return userService.findById(id);
}
// Client requests with:
// Accept: application/json → Returns JSON
// Accept: application/xml → Returns XML
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
@RestController
@RequestMapping("/api/users")
@Tag(name = "User Management", description = "APIs for managing users")
public class UserController {
@Operation(summary = "Get user by ID",
description = "Returns a single user")
@ApiResponses(value = {
@ApiResponse(responseCode = "200", description = "User found"),
@ApiResponse(responseCode = "404", description = "User not found")
})
@GetMapping("/{id}")
public User getUserById(@PathVariable Long id) {
return userService.findById(id);
}
}
Access UI at: http://localhost:8080/swagger-ui.html
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(withDefaults())
.csrf(csrf -> csrf.disable());
return http.build();
}
}
JSON Web Tokens for stateless authentication:
@PostMapping("/login")
public ResponseEntity<AuthResponse> login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getEmail(), request.getPassword()
)
);
String token = jwtTokenProvider.generateToken(authentication);
return ResponseEntity.ok(new AuthResponse(token));
}
// Protected endpoint
@GetMapping("/profile")
public User getProfile(@AuthenticationPrincipal UserDetails userDetails) {
return userService.findByEmail(userDetails.getUsername());
}
Protect your API from abuse:
@Configuration
public class RateLimitConfig {
@Bean
public RateLimiter rateLimiter() {
return RateLimiter.create(100); // 100 requests per second
}
}
@RestControllerAdvice
public class RateLimitInterceptor {
@Autowired
private RateLimiter rateLimiter;
@PreHandle
public boolean preHandle(HttpServletRequest request) {
if (!rateLimiter.tryAcquire()) {
throw new TooManyRequestsException("Rate limit exceeded");
}
return true;
}
}
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@AutoConfigureMockMvc
class UserControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private ObjectMapper objectMapper;
@Test
void shouldCreateUser() throws Exception {
UserDTO user = new UserDTO("john@example.com", "John Doe");
mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(user)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.email").value("john@example.com"))
.andExpect(header().exists("Location"));
}
@Test
void shouldReturnNotFoundForInvalidId() throws Exception {
mockMvc.perform(get("/api/users/999"))
.andExpect(status().isNotFound());
}
}
/api/users, /api/products/api/getUsers, /api/createProduct/api/users/{id}/api/user/{id}Task: Create a complete REST API for a blog application
Task: Add comprehensive API documentation
Task: Secure your API
In this module, you learned:
Next Module: Modern Java Features