From 0ea4892c7321d46a26d90937ab56007c4cfac567 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Sat, 9 May 2026 16:16:48 +0530 Subject: [PATCH 1/3] feat: implement PATCH /players/{squadNumber} for partial updates (#318) --- .../boot/controllers/PlayersController.java | 38 +++++++++++++++ .../spring/boot/models/PlayerPatchDTO.java | 46 +++++++++++++++++++ .../spring/boot/services/PlayersService.java | 42 +++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java index 0563f9b..38cf8bd 100644 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java @@ -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; @@ -219,7 +221,43 @@ public ResponseEntity 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. + *

+ * 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. + *

+ * + * @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 patch( + @PathVariable Integer squadNumber, + @RequestBody PlayerPatchDTO playerPatchDTO) { + if (playerPatchDTO.getSquadNumber() != null || playerPatchDTO.getId() != null) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); + } + boolean patched = playersService.patch(squadNumber, playerPatchDTO); + return (patched) + ? ResponseEntity.status(HttpStatus.NO_CONTENT).build() + : ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } /* * ----------------------------------------------------------------------------------------------------------------------- * HTTP DELETE diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java new file mode 100644 index 0000000..7b9a162 --- /dev/null +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java @@ -0,0 +1,46 @@ +package ar.com.nanotaboada.java.samples.spring.boot.models; + +import java.time.LocalDate; +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import lombok.Data; + +/** + * Data Transfer Object for partial updates to a {@link Player} resource (HTTP PATCH). + *

+ * 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. + *

+ * + *

Immutable Fields:

+ *
    + *
  • {@code id} — surrogate UUID key, must not be present in PATCH requests
  • + *
  • {@code squadNumber} — natural key, must not be present in PATCH requests
  • + *
+ *

+ * If either immutable field is present in the request body, the controller returns + * {@code 400 Bad Request}. + *

+ * + * @see Player + * @see PlayerDTO + * @since 4.0.2025 + */ +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PlayerPatchDTO { + + private UUID id; + private Integer squadNumber; + private String firstName; + private String middleName; + private String lastName; + private LocalDate dateOfBirth; + private String position; + private String abbrPosition; + private String team; + private String league; + private Boolean starting11; +} \ No newline at end of file diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java index 33fb118..ae0f32f 100644 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java @@ -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; @@ -215,6 +216,47 @@ public boolean update(Integer squadNumber, PlayerDTO playerDTO) { }); } + /** + * Partially updates an existing player identified by their squad number. + *

+ * Only the non-null fields in the DTO are applied to the existing entity. + * Fields absent from the request (null) are left unchanged. + *

+ * + * @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) { + log.debug("Patching player with squad number: {}", squadNumber); + + if (squadNumber == null) { + log.warn("Cannot patch player - squad number 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 From 641e79297467f324b5504cb0a9fac9cae342f8d1 Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 11 May 2026 11:11:01 +0530 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20null=20guard,=20@Valid,=20Bean=20Validation,=20patc?= =?UTF-8?q?h=20tests,=20CHANGELOG?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 8 ++ .../boot/controllers/PlayersController.java | 4 +- .../spring/boot/models/PlayerPatchDTO.java | 25 +++++- .../spring/boot/services/PlayersService.java | 4 +- .../test/services/PlayersServiceTests.java | 86 +++++++++++++++++++ 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11be496..0937deb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java index 38cf8bd..c33e388 100644 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java @@ -249,8 +249,8 @@ public ResponseEntity put(@PathVariable Integer squadNumber, @RequestBody }) public ResponseEntity patch( @PathVariable Integer squadNumber, - @RequestBody PlayerPatchDTO playerPatchDTO) { - if (playerPatchDTO.getSquadNumber() != null || playerPatchDTO.getId() != null) { + @RequestBody @Valid PlayerPatchDTO playerPatchDTO) { + if (playerPatchDTO == null || playerPatchDTO.getSquadNumber() != null || playerPatchDTO.getId() != null) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).build(); } boolean patched = playersService.patch(squadNumber, playerPatchDTO); diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java index 7b9a162..cad8cfa 100644 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/PlayerPatchDTO.java @@ -3,6 +3,11 @@ 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; @@ -31,16 +36,34 @@ @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; } \ No newline at end of file diff --git a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java index ae0f32f..187f90b 100644 --- a/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java +++ b/src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java @@ -232,8 +232,8 @@ public boolean update(Integer squadNumber, PlayerDTO playerDTO) { public boolean patch(Integer squadNumber, PlayerPatchDTO playerPatchDTO) { log.debug("Patching player with squad number: {}", squadNumber); - if (squadNumber == null) { - log.warn("Cannot patch player - squad number is null"); + if (squadNumber == null || playerPatchDTO == null) { + log.warn("Cannot patch player - squad number or payload is null"); return false; } diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java index adeaddd..d65ccb8 100644 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java @@ -22,6 +22,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 ar.com.nanotaboada.java.samples.spring.boot.services.PlayersService; import ar.com.nanotaboada.java.samples.spring.boot.test.PlayerDTOFakes; @@ -389,6 +390,91 @@ void givenNullSquadNumber_whenUpdate_thenReturnsFalse() { then(actual).isFalse(); } + /* + * ----------------------------------------------------------------------------------------------------------------------- + * Patch + * ----------------------------------------------------------------------------------------------------------------------- + */ + + /** + * Given a player exists + * When patch() is called with the player's squad number and partial data + * Then the player is updated and true is returned + */ + @Test + void givenExistingPlayer_whenPatch_thenReturnsTrue() { + // Given + Player entity = PlayerFakes.createOneValid(); + Integer squadNumber = entity.getSquadNumber(); + PlayerPatchDTO dto = new PlayerPatchDTO(); + dto.setFirstName("Updated"); + Mockito + .when(playersRepositoryMock.findBySquadNumber(squadNumber)) + .thenReturn(Optional.of(entity)); + // When + boolean actual = playersService.patch(squadNumber, dto); + // Then + verify(playersRepositoryMock, times(1)).findBySquadNumber(squadNumber); + verify(playersRepositoryMock, times(1)).save(entity); + then(actual).isTrue(); + } + + /** + * Given no player exists with the specified squad number + * When patch() is called + * Then false is returned without saving + */ + @Test + void givenNonexistentPlayer_whenPatch_thenReturnsFalse() { + // Given + Integer squadNumber = 999; + PlayerPatchDTO dto = new PlayerPatchDTO(); + dto.setFirstName("Ghost"); + Mockito + .when(playersRepositoryMock.findBySquadNumber(squadNumber)) + .thenReturn(Optional.empty()); + // When + boolean actual = playersService.patch(squadNumber, dto); + // Then + verify(playersRepositoryMock, times(1)).findBySquadNumber(squadNumber); + verify(playersRepositoryMock, never()).save(any(Player.class)); + then(actual).isFalse(); + } + + /** + * Given a null squad number is passed + * When patch() is called + * Then false is returned without hitting the repository + */ + @Test + void givenNullSquadNumber_whenPatch_thenReturnsFalse() { + // Given + PlayerPatchDTO dto = new PlayerPatchDTO(); + // When + boolean actual = playersService.patch(null, dto); + // Then + verify(playersRepositoryMock, never()).findBySquadNumber(any()); + verify(playersRepositoryMock, never()).save(any(Player.class)); + then(actual).isFalse(); + } + + /** + * Given a null payload is passed + * When patch() is called + * Then false is returned without hitting the repository + */ + @Test + void givenNullPayload_whenPatch_thenReturnsFalse() { + // Given + Integer squadNumber = 10; + // When + boolean actual = playersService.patch(squadNumber, null); + // Then + verify(playersRepositoryMock, never()).findBySquadNumber(any()); + verify(playersRepositoryMock, never()).save(any(Player.class)); + then(actual).isFalse(); + } + /* * ----------------------------------------------------------------------------------------------------------------------- * Delete From 082c99740f3dc0d04c9a0cdd4b8e04db8aca326b Mon Sep 17 00:00:00 2001 From: Sunny Sharma Date: Mon, 11 May 2026 11:28:17 +0530 Subject: [PATCH 3/3] =?UTF-8?q?test:=20strengthen=20patch=20semantics=20?= =?UTF-8?q?=E2=80=94=20assert=20updated=20and=20unchanged=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../boot/test/services/PlayersServiceTests.java | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java index d65ccb8..aa9c643 100644 --- a/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java +++ b/src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/services/PlayersServiceTests.java @@ -399,15 +399,20 @@ void givenNullSquadNumber_whenUpdate_thenReturnsFalse() { /** * Given a player exists * When patch() is called with the player's squad number and partial data - * Then the player is updated and true is returned + * Then only the patched field is updated and unchanged fields retain their original values */ @Test void givenExistingPlayer_whenPatch_thenReturnsTrue() { // Given Player entity = PlayerFakes.createOneValid(); Integer squadNumber = entity.getSquadNumber(); + String originalLastName = entity.getLastName(); + String originalPosition = entity.getPosition(); + PlayerPatchDTO dto = new PlayerPatchDTO(); dto.setFirstName("Updated"); + // lastName and position are intentionally absent (null) — must stay unchanged + Mockito .when(playersRepositoryMock.findBySquadNumber(squadNumber)) .thenReturn(Optional.of(entity)); @@ -417,6 +422,10 @@ void givenExistingPlayer_whenPatch_thenReturnsTrue() { verify(playersRepositoryMock, times(1)).findBySquadNumber(squadNumber); verify(playersRepositoryMock, times(1)).save(entity); then(actual).isTrue(); + // Assert patch semantics: patched field changed, untouched fields unchanged + then(entity.getFirstName()).isEqualTo("Updated"); + then(entity.getLastName()).isEqualTo(originalLastName); + then(entity.getPosition()).isEqualTo(originalPosition); } /**