Skip to content

Commit d0d1d04

Browse files
committed
refactor: apply comprehensive code review improvements (#248)
REST API Improvements: - Change PUT endpoint from /players to /players/{id} for REST convention compliance - Add path/body ID mismatch validation (returns 400 Bad Request) - Update all documentation (AGENTS.md, README.md) to reflect /players/{id} pattern Repository & Data Layer: - Upgrade PlayersRepository from CrudRepository to JpaRepository per coding guidelines - Remove redundant findById() declaration (inherited from JpaRepository) - Add @column(unique=true) to squadNumber field matching database UNIQUE constraint - Add UNIQUE constraint on squadNumber in ddl.sql schema Caching Optimizations: - Prevent caching null results in retrieveById() with unless="#result == null" - Avoid serving stale cache misses for non-existent players Date/Time Handling: - Fix IsoDateConverter to use OffsetDateTime.atOffset(ZoneOffset.UTC) instead of manual "Z" concatenation - Use ISO_OFFSET_DATE_TIME formatter for proper timezone-aware conversion - Improve round-trip consistency by parsing with same formatter Test Improvements: - Replace JUnit assertTrue() with AssertJ assertThat().isPresent() for consistency - Fix PUT test failures by setting IDs on PlayerDTO test fixtures - Add put_idMismatch_returnsBadRequest() test for ID validation - Add standard imports (List, Arrays) instead of fully qualified names in test fakes Configuration: - Add explicit spring.jpa.database-platform=SQLiteDialect to test properties - Ensure test/production Hibernate dialect consistency Code Quality: - Apply @requiredargsconstructor to PlayersController per Lombok guidelines - Remove extra whitespace in dml.sql - Fix AGENTS.md architecture tree (remove duplicate IsoDateConverter.java entry) All changes maintain backward compatibility while improving code quality, REST compliance, and cache behavior.
1 parent e08af5f commit d0d1d04

File tree

13 files changed

+97
-58
lines changed

13 files changed

+97
-58
lines changed

AGENTS.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -191,11 +191,10 @@ src/main/java/ar/com/nanotaboada/java/samples/spring/boot/
191191
192192
├── models/ # Domain models
193193
│ ├── Player.java # @Entity, JPA model
194-
│ ├── PlayerDTO.java # Data Transfer Object, validation
195-
│ └── IsoDateConverter.java # JPA converter for ISO-8601 dates
194+
│ └── PlayerDTO.java # Data Transfer Object, validation
196195
197196
└── converters/ # Infrastructure converters
198-
└── IsoDateConverter.java # JPA converter
197+
└── IsoDateConverter.java # JPA converter for ISO-8601 dates
199198
200199
src/test/java/ # Test classes
201200
├── PlayersControllerTests.java
@@ -226,7 +225,7 @@ src/test/java/ # Test classes
226225
| `GET` | `/players/search/league/{league}` | Search players by league |
227226
| `GET` | `/players/search/squadnumber/{squadNumber}` | Get player by squad number |
228227
| `POST` | `/players` | Create new player |
229-
| `PUT` | `/players` | Update player |
228+
| `PUT` | `/players/{id}` | Update player |
230229
| `DELETE` | `/players/{id}` | Delete player |
231230
| `GET` | `/actuator/health` | Health check |
232231
| `GET` | `/swagger-ui.html` | API documentation |
@@ -344,7 +343,7 @@ curl -X POST http://localhost:8080/players \
344343
}'
345344

346345
# Update player
347-
curl -X PUT http://localhost:8080/players \
346+
curl -X PUT http://localhost:8080/players/1 \
348347
-H "Content-Type: application/json" \
349348
-d '{
350349
"id": 1,

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ Interactive API documentation is available via Swagger UI at `http://localhost:9
173173
- `GET /players/search/league/{league}` - Search players by league
174174
- `GET /players/search/squadnumber/{squadNumber}` - Get player by squad number
175175
- `POST /players` - Create new player
176-
- `PUT /players` - Update existing player
176+
- `PUT /players/{id}` - Update existing player
177177
- `DELETE /players/{id}` - Remove player
178178
- `GET /actuator/health` - Health check
179179

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/controllers/PlayersController.java

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import io.swagger.v3.oas.annotations.responses.ApiResponses;
2626
import io.swagger.v3.oas.annotations.tags.Tag;
2727
import jakarta.validation.Valid;
28+
import lombok.RequiredArgsConstructor;
2829

2930
/**
3031
* REST Controller for managing Player resources.
@@ -59,14 +60,11 @@
5960
*/
6061
@RestController
6162
@Tag(name = "Players")
63+
@RequiredArgsConstructor
6264
public class PlayersController {
6365

6466
private final PlayersService playersService;
6567

66-
public PlayersController(PlayersService playersService) {
67-
this.playersService = playersService;
68-
}
69-
7068
/*
7169
* -----------------------------------------------------------------------------------------------------------------------
7270
* HTTP POST
@@ -203,20 +201,27 @@ public ResponseEntity<PlayerDTO> searchBySquadNumber(@PathVariable Integer squad
203201
/**
204202
* Updates an existing player resource (full update).
205203
* <p>
206-
* Performs a complete replacement of the player entity. Requires a valid player ID in the request body.
204+
* Performs a complete replacement of the player entity. The ID in the path must match the ID in the request body.
207205
* </p>
208206
*
209-
* @param playerDTO the complete player data (must include valid ID and pass validation)
210-
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails
207+
* @param id the unique identifier of the player to update
208+
* @param playerDTO the complete player data (must pass validation)
209+
* @return 204 No Content if successful, 404 Not Found if player doesn't exist, or 400 Bad Request if validation fails or
210+
* ID mismatch
211211
*/
212-
@PutMapping("/players")
212+
@PutMapping("/players/{id}")
213213
@Operation(summary = "Updates (entirely) a player by ID")
214214
@ApiResponses(value = {
215215
@ApiResponse(responseCode = "204", description = "No Content", content = @Content),
216216
@ApiResponse(responseCode = "400", description = "Bad Request", content = @Content),
217217
@ApiResponse(responseCode = "404", description = "Not Found", content = @Content)
218218
})
219-
public ResponseEntity<Void> put(@RequestBody @Valid PlayerDTO playerDTO) {
219+
public ResponseEntity<Void> put(@PathVariable Long id, @RequestBody @Valid PlayerDTO playerDTO) {
220+
// Ensure path ID matches body ID
221+
if (playerDTO.getId() != null && !playerDTO.getId().equals(id)) {
222+
return ResponseEntity.status(HttpStatus.BAD_REQUEST).build();
223+
}
224+
playerDTO.setId(id); // Set ID from path to ensure consistency
220225
boolean updated = playersService.update(playerDTO);
221226
return (updated)
222227
? ResponseEntity.status(HttpStatus.NO_CONTENT).build()

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/converters/IsoDateConverter.java

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ar.com.nanotaboada.java.samples.spring.boot.converters;
22

33
import java.time.LocalDate;
4+
import java.time.OffsetDateTime;
5+
import java.time.ZoneOffset;
46
import java.time.format.DateTimeFormatter;
57

68
import jakarta.persistence.AttributeConverter;
@@ -26,7 +28,7 @@
2628
* </ul>
2729
*
2830
* <h3>Usage Example:</h3>
29-
*
31+
*
3032
* <pre>
3133
* {
3234
* &#64;code
@@ -45,22 +47,22 @@
4547
@Converter
4648
public class IsoDateConverter implements AttributeConverter<LocalDate, String> {
4749

48-
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_DATE_TIME;
50+
private static final DateTimeFormatter ISO_FORMATTER = DateTimeFormatter.ISO_OFFSET_DATE_TIME;
4951

5052
/**
5153
* Converts a {@link LocalDate} to an ISO-8601 formatted string for database
5254
* storage.
5355
*
5456
* @param date the LocalDate to convert (may be null)
5557
* @return ISO-8601 formatted string (e.g., "1992-09-02T00:00:00Z"), or null if
56-
* input is null
58+
* input is null
5759
*/
5860
@Override
5961
public String convertToDatabaseColumn(LocalDate date) {
6062
if (date == null) {
6163
return null;
6264
}
63-
return date.atStartOfDay().format(ISO_FORMATTER) + "Z";
65+
return date.atStartOfDay().atOffset(ZoneOffset.UTC).format(ISO_FORMATTER);
6466
}
6567

6668
/**
@@ -72,7 +74,7 @@ public String convertToDatabaseColumn(LocalDate date) {
7274
* </p>
7375
*
7476
* @param dateString the ISO-8601 formatted string from the database (may be
75-
* null or blank)
77+
* null or blank)
7678
* @return the corresponding LocalDate, or null if input is null or blank
7779
*/
7880
@Override
@@ -82,8 +84,8 @@ public LocalDate convertToEntityAttribute(String dateString) {
8284
}
8385
// Handle both "YYYY-MM-DD" and "YYYY-MM-DDTHH:mm:ss.SSSZ" formats
8486
if (dateString.contains("T")) {
85-
return LocalDate.parse(dateString.substring(0, 10));
87+
return OffsetDateTime.parse(dateString, ISO_FORMATTER).toLocalDate();
8688
}
87-
return LocalDate.parse(dateString);
89+
return LocalDate.parse(dateString, DateTimeFormatter.ISO_DATE);
8890
}
8991
}

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/models/Player.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
99

1010
import ar.com.nanotaboada.java.samples.spring.boot.converters.IsoDateConverter;
11+
import jakarta.persistence.Column;
1112
import jakarta.persistence.Convert;
1213
import jakarta.persistence.Entity;
1314
import jakarta.persistence.GeneratedValue;
@@ -57,7 +58,17 @@ public class Player {
5758
@JsonSerialize(using = LocalDateSerializer.class)
5859
@Convert(converter = IsoDateConverter.class)
5960
private LocalDate dateOfBirth;
61+
62+
/**
63+
* Squad number (jersey number) - unique natural key.
64+
* <p>
65+
* Used for player lookups via /players/search/squadnumber/{squadNumber}.
66+
* Database constraint enforces uniqueness.
67+
* </p>
68+
*/
69+
@Column(unique = true)
6070
private Integer squadNumber;
71+
6172
private String position;
6273
private String abbrPosition;
6374
private String team;

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/repositories/PlayersRepository.java

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import java.util.List;
44
import java.util.Optional;
55

6+
import org.springframework.data.jpa.repository.JpaRepository;
67
import org.springframework.data.jpa.repository.Query;
7-
import org.springframework.data.repository.CrudRepository;
88
import org.springframework.data.repository.query.Param;
99
import org.springframework.stereotype.Repository;
1010

@@ -14,12 +14,12 @@
1414
* Spring Data JPA Repository for {@link Player} entities.
1515
* <p>
1616
* Provides data access methods for the {@code players} table using Spring Data's repository abstraction.
17-
* Extends {@link CrudRepository} for basic CRUD operations and defines custom queries for advanced search functionality.
17+
* Extends {@link JpaRepository} for CRUD operations, batch operations, and query methods.
1818
* </p>
1919
*
2020
* <h3>Provided Methods:</h3>
2121
* <ul>
22-
* <li><b>Inherited from CrudRepository:</b> save, findAll, findById, delete, etc.</li>
22+
* <li><b>Inherited from JpaRepository:</b> save, findAll (returns List), findById, delete, flush, etc.</li>
2323
* <li><b>Custom Query Methods:</b> League search with case-insensitive wildcard matching</li>
2424
* <li><b>Derived Queries:</b> findBySquadNumber (method name conventions)</li>
2525
* </ul>
@@ -31,28 +31,14 @@
3131
* </ul>
3232
*
3333
* @see Player
34-
* @see org.springframework.data.repository.CrudRepository
34+
* @see org.springframework.data.jpa.repository.JpaRepository
3535
* @see <a href=
3636
* "https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation">Query
3737
* Creation from Method Names</a>
3838
* @since 4.0.2025
3939
*/
4040
@Repository
41-
public interface PlayersRepository extends CrudRepository<Player, Long> {
42-
/**
43-
* Finds a player by their unique identifier.
44-
* <p>
45-
* This is a derived query method - Spring Data JPA automatically implements it based on the method name convention.
46-
* </p>
47-
*
48-
* @param id the unique identifier of the player
49-
* @return an Optional containing the player if found, empty Optional otherwise
50-
* @see <a href=
51-
* "https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#repositories.query-methods.query-creation">Query
52-
* Creation from Method Names</a>
53-
*/
54-
// Non-default methods in interfaces are not shown in coverage reports https://www.jacoco.org/jacoco/trunk/doc/faq.html
55-
Optional<Player> findById(Long id);
41+
public interface PlayersRepository extends JpaRepository<Player, Long> {
5642

5743
/**
5844
* Finds players by league name using case-insensitive wildcard matching.

src/main/java/ar/com/nanotaboada/java/samples/spring/boot/services/PlayersService.java

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package ar.com.nanotaboada.java.samples.spring.boot.services;
22

33
import java.util.List;
4-
import java.util.stream.StreamSupport;
54

65
import org.modelmapper.ModelMapper;
76
import org.springframework.cache.annotation.CacheEvict;
@@ -98,13 +97,14 @@ public PlayerDTO create(PlayerDTO playerDTO) {
9897
* <p>
9998
* This method uses caching to improve performance. If the player is found in the cache, it will be returned without
10099
* hitting the database. Otherwise, it queries the database and caches the result.
100+
* Null results (player not found) are not cached to avoid serving stale misses.
101101
* </p>
102102
*
103103
* @param id the unique identifier of the player (must not be null)
104104
* @return the player DTO if found, null otherwise
105105
* @see org.springframework.cache.annotation.Cacheable
106106
*/
107-
@Cacheable(value = "players", key = "#id")
107+
@Cacheable(value = "players", key = "#id", unless = "#result == null")
108108
public PlayerDTO retrieveById(Long id) {
109109
return playersRepository.findById(id)
110110
.map(this::mapFrom)
@@ -123,7 +123,8 @@ public PlayerDTO retrieveById(Long id) {
123123
*/
124124
@Cacheable(value = "players")
125125
public List<PlayerDTO> retrieveAll() {
126-
return StreamSupport.stream(playersRepository.findAll().spliterator(), false)
126+
return playersRepository.findAll()
127+
.stream()
127128
.map(this::mapFrom)
128129
.toList();
129130
}

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerDTOFakes.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ar.com.nanotaboada.java.samples.spring.boot.test;
22

33
import java.time.LocalDate;
4+
import java.util.Arrays;
5+
import java.util.List;
46

57
import ar.com.nanotaboada.java.samples.spring.boot.models.PlayerDTO;
68

@@ -126,8 +128,8 @@ public static PlayerDTO createOneInvalid() {
126128
* Note: Repository tests query real in-memory DB directly (25 players
127129
* pre-seeded)
128130
*/
129-
public static java.util.List<PlayerDTO> createAll() {
130-
return java.util.Arrays.asList(
131+
public static List<PlayerDTO> createAll() {
132+
return Arrays.asList(
131133
// Starting 11
132134
createPlayerDTOWithId(1L, "Damián", "Emiliano", "Martínez", LocalDate.of(1992, 9, 2), 23, "Goalkeeper",
133135
"GK", "Aston Villa FC", "Premier League", true),

src/test/java/ar/com/nanotaboada/java/samples/spring/boot/test/PlayerFakes.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ar.com.nanotaboada.java.samples.spring.boot.test;
22

33
import java.time.LocalDate;
4+
import java.util.Arrays;
5+
import java.util.List;
46

57
import ar.com.nanotaboada.java.samples.spring.boot.models.Player;
68

@@ -129,8 +131,8 @@ public static Player createOneInvalid() {
129131
* Note: Repository tests query real in-memory DB directly (25 players
130132
* pre-seeded)
131133
*/
132-
public static java.util.List<Player> createAll() {
133-
return java.util.Arrays.asList(
134+
public static List<Player> createAll() {
135+
return Arrays.asList(
134136
// Starting 11
135137
createPlayerWithId(1L, "Damián", "Emiliano", "Martínez", LocalDate.of(1992, 9, 2), 23, "Goalkeeper",
136138
"GK", "Aston Villa FC", "Premier League", true),

0 commit comments

Comments
 (0)