Skip to content
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ Release names follow the **historic football clubs** naming convention (A–Z):

### Added

- `PATCH /players/{squadNumber}` endpoint for partial player updates following
RFC 7396 (JSON Merge Patch) semantics ([#318](https://github.com/nanotaboada/java.samples.spring.boot/issues/318))
— added `PlayerPatchDTO` with nullable fields and `@JsonInclude(NON_NULL)`,
`patch()` service method applying only non-null fields via Optional chain,
and `@PatchMapping` controller with Swagger `@Operation`/`@ApiResponses`
documentation; returns `204` on success, `400` if immutable fields present,
`404` if player not found

### Changed

- Refactor `/pre-release` Phase 2: inline build and test steps directly
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;

import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerPatchDTO;
import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO;
import ar.com.nanotaboada.java.samples.spring.boot.services.PlayersService;
import io.swagger.v3.oas.annotations.Operation;
Expand Down Expand Up @@ -219,7 +221,43 @@ public ResponseEntity<Void> put(@PathVariable Integer squadNumber, @RequestBody
? ResponseEntity.status(HttpStatus.NO_CONTENT).build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
/*
* -----------------------------------------------------------------------------------------------------------------------
* HTTP PATCH
* -----------------------------------------------------------------------------------------------------------------------
*/

/**
* Partially updates an existing player resource identified by squad number.
* <p>
* Only the fields present in the request body are updated; absent fields retain
* their current values. The {@code squadNumber} and {@code id} fields are immutable
* and must not be included in the request body.
* </p>
*
* @param squadNumber the squad number (natural key) of the player to patch
* @param playerPatchDTO the partial player data to apply (only non-null fields are applied)
* @return 204 No Content if successful, 400 Bad Request if body contains squadNumber or id,
* or 404 Not Found if player doesn't exist
*/
@PatchMapping("/players/{squadNumber}")
@Operation(summary = "Partially updates a player by squad number")
@ApiResponses(value = {
@ApiResponse(responseCode = "204", description = "No Content", content = @Content),
@ApiResponse(responseCode = "400", description = "Bad Request - Body must not contain squadNumber or id", content = @Content),
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
})
public ResponseEntity<Void> patch(
@PathVariable Integer squadNumber,
@RequestBody @Valid PlayerPatchDTO playerPatchDTO) {
if (playerPatchDTO == null || playerPatchDTO.getSquadNumber() != null || playerPatchDTO.getId() != null) {
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
boolean patched = playersService.patch(squadNumber, playerPatchDTO);
return (patched)
? ResponseEntity.status(HttpStatus.NO_CONTENT).build()
: ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
/*
* -----------------------------------------------------------------------------------------------------------------------
* HTTP DELETE
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package ar.com.nanotaboada.java.samples.spring.boot.models;

import java.time.LocalDate;
import java.util.UUID;

import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Past;
import jakarta.validation.constraints.Size;

import com.fasterxml.jackson.annotation.JsonInclude;

import lombok.Data;

/**
* Data Transfer Object for partial updates to a {@link Player} resource (HTTP PATCH).
* <p>
* All fields are nullable. Only non-null fields are applied to the existing entity;
* absent fields (null) are left unchanged — following RFC 7396 (JSON Merge Patch) semantics.
* </p>
*
* <h3>Immutable Fields:</h3>
* <ul>
* <li>{@code id} — surrogate UUID key, must not be present in PATCH requests</li>
* <li>{@code squadNumber} — natural key, must not be present in PATCH requests</li>
* </ul>
* <p>
* If either immutable field is present in the request body, the controller returns
* {@code 400 Bad Request}.
* </p>
*
* @see Player
* @see PlayerDTO
* @since 4.0.2025
*/
@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class PlayerPatchDTO {
private UUID id;

@Min(1) @Max(99)
private Integer squadNumber;

@Size(max = 50)
private String firstName;

@Size(max = 50)
private String middleName;

@Size(max = 50)
private String lastName;

@Past
private LocalDate dateOfBirth;

@Size(max = 50)
private String position;

@Size(max = 10)
private String abbrPosition;

@Size(max = 100)
private String team;

@Size(max = 100)
private String league;

private Boolean starting11;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import ar.com.nanotaboada.java.samples.spring.boot.models.Player;
import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO;
import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerPatchDTO;
import ar.com.nanotaboada.java.samples.spring.boot.repositories.PlayersRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -215,6 +216,47 @@
});
}

/**
* Partially updates an existing player identified by their squad number.
* <p>
* Only the non-null fields in the DTO are applied to the existing entity.
* Fields absent from the request (null) are left unchanged.
* </p>
*
* @param squadNumber the squad number (natural key) of the player to patch
* @param playerPatchDTO the partial player data to apply
* @return true if the player was patched successfully, false if not found
*/
@Transactional
@CacheEvict(value = "players", allEntries = true)
public boolean patch(Integer squadNumber, PlayerPatchDTO playerPatchDTO) {

Check failure on line 232 in src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 20 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=nanotaboada_java.samples.spring.boot&issues=AZ4MWlftwyfbI-4OXVvq&open=AZ4MWlftwyfbI-4OXVvq&pullRequest=329
log.debug("Patching player with squad number: {}", squadNumber);

if (squadNumber == null || playerPatchDTO == null) {
log.warn("Cannot patch player - squad number or payload is null");
return false;
}

return playersRepository.findBySquadNumber(squadNumber)
.map(existing -> {
if (playerPatchDTO.getFirstName() != null) existing.setFirstName(playerPatchDTO.getFirstName());
if (playerPatchDTO.getMiddleName() != null) existing.setMiddleName(playerPatchDTO.getMiddleName());
if (playerPatchDTO.getLastName() != null) existing.setLastName(playerPatchDTO.getLastName());
if (playerPatchDTO.getDateOfBirth() != null) existing.setDateOfBirth(playerPatchDTO.getDateOfBirth());
if (playerPatchDTO.getPosition() != null) existing.setPosition(playerPatchDTO.getPosition());
if (playerPatchDTO.getAbbrPosition() != null) existing.setAbbrPosition(playerPatchDTO.getAbbrPosition());
if (playerPatchDTO.getTeam() != null) existing.setTeam(playerPatchDTO.getTeam());
if (playerPatchDTO.getLeague() != null) existing.setLeague(playerPatchDTO.getLeague());
if (playerPatchDTO.getStarting11() != null) existing.setStarting11(playerPatchDTO.getStarting11());
playersRepository.save(existing);
log.info("Player patched successfully - Squad Number: {}", squadNumber);
return true;
})
.orElseGet(() -> {
log.warn("Cannot patch player - squad number {} not found", squadNumber);
return false;
});
}
/*
* -----------------------------------------------------------------------------------------------------------------------
* Delete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.List;
import java.util.UUID;

import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerPatchDTO;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
Expand Down Expand Up @@ -590,4 +591,128 @@ void givenUnknownPlayer_whenDelete_thenReturnsNotFound()
verify(playersServiceMock, times(1)).deleteBySquadNumber(squadNumber);
then(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
}


/*
* -------------------------------------------------------------------------
* HTTP PATCH
* -------------------------------------------------------------------------
*/

/**
* Given the request body contains squadNumber (immutable field)
* When attempting to patch a player
* Then response status is 400 Bad Request and service is never called
*/
@Test
void givenBodyContainsSquadNumber_whenPatch_thenReturnsBadRequest()
throws Exception {
// Given
Integer squadNumber = 10;
PlayerPatchDTO dto = new PlayerPatchDTO();
dto.setSquadNumber(squadNumber); // forbidden field
dto.setFirstName("Lionel");
String content = objectMapper.writeValueAsString(dto);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.patch(PATH + "/{squadNumber}", squadNumber)
.content(content)
.contentType(MediaType.APPLICATION_JSON);
// When
MockHttpServletResponse response = application
.perform(request)
.andReturn()
.getResponse();
// Then
verify(playersServiceMock, never()).patch(anyInt(), any(PlayerPatchDTO.class));
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}

/**
* Given the request body contains id (immutable field)
* When attempting to patch a player
* Then response status is 400 Bad Request and service is never called
*/
@Test
void givenBodyContainsId_whenPatch_thenReturnsBadRequest()
throws Exception {
// Given
Integer squadNumber = 10;
PlayerPatchDTO dto = new PlayerPatchDTO();
dto.setId(UUID.randomUUID()); // forbidden field
dto.setFirstName("Lionel");
String content = objectMapper.writeValueAsString(dto);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.patch(PATH + "/{squadNumber}", squadNumber)
.content(content)
.contentType(MediaType.APPLICATION_JSON);
// When
MockHttpServletResponse response = application
.perform(request)
.andReturn()
.getResponse();
// Then
verify(playersServiceMock, never()).patch(anyInt(), any(PlayerPatchDTO.class));
then(response.getStatus()).isEqualTo(HttpStatus.BAD_REQUEST.value());
}

/**
* Given a player exists and valid partial data is provided
* When patching that player by squad number
* Then response status is 204 No Content
*/
@Test
void givenPlayerExists_whenPatch_thenReturnsNoContent()
throws Exception {
// Given
Integer squadNumber = 10;
PlayerPatchDTO dto = new PlayerPatchDTO();
dto.setFirstName("Lionel");
String content = objectMapper.writeValueAsString(dto);
Mockito
.when(playersServiceMock.patch(squadNumber, dto))
.thenReturn(true);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.patch(PATH + "/{squadNumber}", squadNumber)
.content(content)
.contentType(MediaType.APPLICATION_JSON);
// When
MockHttpServletResponse response = application
.perform(request)
.andReturn()
.getResponse();
// Then
verify(playersServiceMock, times(1)).patch(anyInt(), any(PlayerPatchDTO.class));
then(response.getStatus()).isEqualTo(HttpStatus.NO_CONTENT.value());
}

/**
* Given a player with the provided squad number does not exist
* When attempting to patch that player
* Then response status is 404 Not Found
*/
@Test
void givenUnknownPlayer_whenPatch_thenReturnsNotFound()
throws Exception {
// Given
Integer squadNumber = 99;
PlayerPatchDTO dto = new PlayerPatchDTO();
dto.setFirstName("Unknown");
String content = objectMapper.writeValueAsString(dto);
Mockito
.when(playersServiceMock.patch(anyInt(), any(PlayerPatchDTO.class)))
.thenReturn(false);
MockHttpServletRequestBuilder request = MockMvcRequestBuilders
.patch(PATH + "/{squadNumber}", squadNumber)
.content(content)
.contentType(MediaType.APPLICATION_JSON);
// When
MockHttpServletResponse response = application
.perform(request)
.andReturn()
.getResponse();
// Then
verify(playersServiceMock, times(1)).patch(anyInt(), any(PlayerPatchDTO.class));
then(response.getStatus()).isEqualTo(HttpStatus.NOT_FOUND.value());
}
}

Loading