Skip to content

Commit 470fa53

Browse files
nanotaboadaclaude
andcommitted
refactor(api): use squad number for PUT and DELETE endpoints (#521)
BREAKING CHANGE: PUT /players/{player_id} → PUT /players/squadnumber/{squad_number} BREAKING CHANGE: DELETE /players/{player_id} → DELETE /players/squadnumber/{squad_number} Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8056837 commit 470fa53

File tree

10 files changed

+111
-80
lines changed

10 files changed

+111
-80
lines changed

.claude/settings.json

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
{
2-
"model": "claude-sonnet-4-6"
2+
"model": "claude-sonnet-4-6",
3+
"permissions": {
4+
"allow": [
5+
"Bash(gh issue:*)",
6+
"Bash(gh auth:*)",
7+
"Bash(uv run:*)",
8+
"Bash(source .venv/bin/activate)",
9+
"WebFetch(domain:github.com)",
10+
"WebFetch(domain:api.github.com)"
11+
]
12+
}
313
}

.github/copilot-instructions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ tests/ — pytest integration tests
4040
- **Async**: All routes and service functions must be `async def`; use `AsyncSession` (never `Session`); use `aiosqlite` (never `sqlite3`); use SQLAlchemy 2.0 `select()` (never `session.query()`)
4141
- **API contract**: camelCase JSON via Pydantic `alias_generator=to_camel`; Python internals stay snake_case
4242
- **Models**: `PlayerRequestModel` (no `id`, used for POST/PUT) and `PlayerResponseModel` (includes `id: UUID`, used for GET/POST responses); never use the removed `PlayerModel`
43-
- **Primary key**: UUID surrogate key (`id`) — opaque, internal, used for all CRUD operations. UUID v4 for API-created records; UUID v5 (deterministic) for migration-seeded records. `squad_number` is the natural key — human-readable, domain-meaningful, preferred lookup for external consumers
43+
- **Primary key**: UUID surrogate key (`id`) — opaque, internal, used for GET by id only. UUID v4 for API-created records; UUID v5 (deterministic) for migration-seeded records. `squad_number` is the natural key — human-readable, domain-meaningful, used for all mutation endpoints (PUT, DELETE) and preferred for all external consumers
4444
- **Caching**: cache key `"players"` (hardcoded); clear on POST/PUT/DELETE; `X-Cache` header (HIT/MISS)
4545
- **Errors**: Catch specific exceptions with rollback in services; Pydantic validation returns 422 (not 400)
4646
- **Logging**: `logging` module only; never `print()`

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,9 @@ dmypy.json
160160
# Cython debug symbols
161161
cython_debug/
162162

163+
# Claude Code
164+
.claude/settings.local.json
165+
163166
# PyCharm
164167
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
165168
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore

.markdownlint.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"MD013": false,
3+
"MD024": {
4+
"siblings_only": true
5+
}
6+
}

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ This project uses famous football coaches as release codenames, following an A-Z
4242

4343
## [Unreleased]
4444

45+
### Changed
46+
47+
- **BREAKING**: `PUT /players/{player_id}` replaced by `PUT /players/squadnumber/{squad_number}` — mutation endpoints now use Squad Number (natural key) instead of UUID (surrogate key), consistent with `GET /players/squadnumber/{squad_number}` (#521)
48+
- **BREAKING**: `DELETE /players/{player_id}` replaced by `DELETE /players/squadnumber/{squad_number}` — same rationale as above (#521)
49+
- `update_async` and `delete_async` (UUID-based) replaced by `update_by_squad_number_async` and `delete_by_squad_number_async` in `services/player_service.py` (#521)
50+
4551
---
4652

4753
## [1.1.0 - Bielsa] - 2026-03-02

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,8 +184,8 @@ Interactive API documentation is available via Swagger UI at `http://localhost:9
184184
- `GET /players/{player_id}` — Get player by UUID (surrogate key)
185185
- `GET /players/squadnumber/{squad_number}` — Get player by squad number (natural key)
186186
- `POST /players/` — Create a new player
187-
- `PUT /players/{player_id}` — Update an existing player
188-
- `DELETE /players/{player_id}` — Remove a player
187+
- `PUT /players/squadnumber/{squad_number}` — Update an existing player
188+
- `DELETE /players/squadnumber/{squad_number}` — Remove a player
189189
- `GET /health` — Health check
190190

191191
### HTTP Requests

rest/players.rest

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
### https://marketplace.visualstudio.com/items?itemName=humao.rest-client
66
###
77
### Key design note:
8-
### squad_number = natural key (domain-meaningful, preferred for lookups)
9-
### id (UUID) = surrogate key (internal, opaque, used for CRUD operations)
8+
### squad_number = natural key (domain-meaningful, used for all CRUD operations)
9+
### id (UUID) = surrogate key (internal, opaque, used for GET by id only)
1010

1111
@baseUrl = http://localhost:9000
1212

@@ -68,12 +68,12 @@ GET {{baseUrl}}/players/squadnumber/10
6868
Accept: application/json
6969

7070
# ------------------------------------------------------------------------------
71-
# PUT /players/{player_id} — Update
72-
# Emiliano Martínez (squad 23): UUID v5, seeded by seed_001.
71+
# PUT /players/squadnumber/{squad_number} — Update
72+
# Emiliano Martínez (squad 23): seeded by seed_001.
7373
# ------------------------------------------------------------------------------
7474

75-
### PUT /players/{player_id} — Update an existing Player
76-
PUT {{baseUrl}}/players/b04965e6-a9bb-591f-8f8a-1adcb2c8dc39
75+
### PUT /players/squadnumber/{squad_number} — Update an existing Player
76+
PUT {{baseUrl}}/players/squadnumber/23
7777
Content-Type: application/json
7878

7979
{
@@ -90,15 +90,9 @@ Content-Type: application/json
9090
}
9191

9292
# ------------------------------------------------------------------------------
93-
# DELETE /players/{player_id} — Delete
94-
# Thiago Almada (squad 16): created by POST above. Since the UUID is generated
95-
# at runtime, retrieve it first via squad number, then substitute it below.
93+
# DELETE /players/squadnumber/{squad_number} — Delete
94+
# Thiago Almada (squad 16): created by POST above.
9695
# ------------------------------------------------------------------------------
9796

98-
### Step 1 — Look up Almada's UUID by squad number
99-
GET {{baseUrl}}/players/squadnumber/16
100-
Accept: application/json
101-
102-
### Step 2 — Delete Almada using the UUID returned above
103-
# Replace {player_id} with the id field from the response above.
104-
DELETE {{baseUrl}}/players/{player_id}
97+
### DELETE /players/squadnumber/{squad_number} — Delete an existing Player
98+
DELETE {{baseUrl}}/players/squadnumber/16

routes/player_route.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
(surrogate key, internal).
1616
- GET /players/squadnumber/{squad_number} : Retrieve Player by Squad Number
1717
(natural key, domain).
18-
- PUT /players/{player_id} : Update an existing Player.
19-
- DELETE /players/{player_id} : Delete an existing Player.
18+
- PUT /players/squadnumber/{squad_number} : Update an existing Player.
19+
- DELETE /players/squadnumber/{squad_number} : Delete an existing Player.
2020
"""
2121

2222
from typing import List
@@ -177,33 +177,37 @@ async def get_by_squad_number_async(
177177

178178

179179
@api_router.put(
180-
"/players/{player_id}",
180+
"/players/squadnumber/{squad_number}",
181181
status_code=status.HTTP_204_NO_CONTENT,
182182
summary="Updates an existing Player",
183183
tags=["Players"],
184184
)
185185
async def put_async(
186-
player_id: UUID = Path(..., title="The UUID of the Player"),
186+
squad_number: int = Path(..., title="The Squad Number of the Player"),
187187
player_model: PlayerRequestModel = Body(...),
188188
async_session: AsyncSession = Depends(generate_async_session),
189189
):
190190
"""
191191
Endpoint to entirely update an existing Player.
192192
193193
Args:
194-
player_id (UUID): The UUID of the Player to update.
194+
squad_number (int): The Squad Number of the Player to update.
195195
player_model (PlayerRequestModel): The Pydantic model representing the Player
196196
to update.
197197
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
198198
199199
Raises:
200-
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
201-
does not exist.
200+
HTTPException: HTTP 404 Not Found error if the Player with the specified Squad
201+
Number does not exist.
202202
"""
203-
player = await player_service.retrieve_by_id_async(async_session, player_id)
203+
player = await player_service.retrieve_by_squad_number_async(
204+
async_session, squad_number
205+
)
204206
if not player:
205207
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
206-
updated = await player_service.update_async(async_session, player_id, player_model)
208+
updated = await player_service.update_by_squad_number_async(
209+
async_session, squad_number, player_model
210+
)
207211
if not updated: # pragma: no cover
208212
raise HTTPException(
209213
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
@@ -216,30 +220,34 @@ async def put_async(
216220

217221

218222
@api_router.delete(
219-
"/players/{player_id}",
223+
"/players/squadnumber/{squad_number}",
220224
status_code=status.HTTP_204_NO_CONTENT,
221225
summary="Deletes an existing Player",
222226
tags=["Players"],
223227
)
224228
async def delete_async(
225-
player_id: UUID = Path(..., title="The UUID of the Player"),
229+
squad_number: int = Path(..., title="The Squad Number of the Player"),
226230
async_session: AsyncSession = Depends(generate_async_session),
227231
):
228232
"""
229233
Endpoint to delete an existing Player.
230234
231235
Args:
232-
player_id (UUID): The UUID of the Player to delete.
236+
squad_number (int): The Squad Number of the Player to delete.
233237
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
234238
235239
Raises:
236-
HTTPException: HTTP 404 Not Found error if the Player with the specified UUID
237-
does not exist.
240+
HTTPException: HTTP 404 Not Found error if the Player with the specified Squad
241+
Number does not exist.
238242
"""
239-
player = await player_service.retrieve_by_id_async(async_session, player_id)
243+
player = await player_service.retrieve_by_squad_number_async(
244+
async_session, squad_number
245+
)
240246
if not player:
241247
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
242-
deleted = await player_service.delete_async(async_session, player_id)
248+
deleted = await player_service.delete_by_squad_number_async(
249+
async_session, squad_number
250+
)
243251
if not deleted: # pragma: no cover
244252
raise HTTPException(
245253
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

services/player_service.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22
Async CRUD operations for Player entities using SQLAlchemy ORM.
33
44
Functions:
5-
- create_async : Add a new Player to the database.
6-
- retrieve_all_async : Fetch all Player records.
7-
- retrieve_by_id_async : Fetch a Player by its UUID
8-
(surrogate key, internal).
9-
- retrieve_by_squad_number_async : Fetch a Player by its Squad Number
10-
(natural key, domain).
11-
- update_async : Fully update an existing Player.
12-
- delete_async : Remove a Player from the database.
5+
- create_async : Add a new Player to the database.
6+
- retrieve_all_async : Fetch all Player records.
7+
- retrieve_by_id_async : Fetch a Player by its UUID
8+
(surrogate key, internal).
9+
- retrieve_by_squad_number_async : Fetch a Player by its Squad Number
10+
(natural key, domain).
11+
- update_by_squad_number_async : Fully update a Player by Squad Number.
12+
- delete_by_squad_number_async : Remove a Player by Squad Number.
1313
1414
Handles SQLAlchemy exceptions with transaction rollback and logs errors.
1515
"""
@@ -117,24 +117,24 @@ async def retrieve_by_squad_number_async(
117117
# Update -----------------------------------------------------------------------
118118

119119

120-
async def update_async(
121-
async_session: AsyncSession, player_id: UUID, player_model: PlayerRequestModel
120+
async def update_by_squad_number_async(
121+
async_session: AsyncSession, squad_number: int, player_model: PlayerRequestModel
122122
) -> bool:
123123
"""
124-
Updates (entirely) an existing Player in the database.
124+
Updates (entirely) an existing Player identified by Squad Number.
125125
126126
Args:
127127
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
128-
player_id (UUID): The UUID of the Player to update.
128+
squad_number (int): The Squad Number of the Player to update.
129129
player_model (PlayerRequestModel): The Pydantic model representing the Player
130130
to update.
131131
132132
Returns:
133133
True if the Player was updated successfully, False otherwise.
134134
"""
135-
player = await async_session.get(Player, player_id)
135+
player = await retrieve_by_squad_number_async(async_session, squad_number)
136136
if player is None: # pragma: no cover
137-
logger.error("Player not found for update: %s", player_id)
137+
logger.error("Player not found for update: squad_number=%s", squad_number)
138138
return False
139139
player.first_name = player_model.first_name
140140
player.middle_name = player_model.middle_name
@@ -158,18 +158,20 @@ async def update_async(
158158
# Delete -----------------------------------------------------------------------
159159

160160

161-
async def delete_async(async_session: AsyncSession, player_id: UUID) -> bool:
161+
async def delete_by_squad_number_async(
162+
async_session: AsyncSession, squad_number: int
163+
) -> bool:
162164
"""
163-
Deletes an existing Player from the database.
165+
Deletes an existing Player identified by Squad Number from the database.
164166
165167
Args:
166168
async_session (AsyncSession): The async version of a SQLAlchemy ORM session.
167-
player_id (UUID): The UUID of the Player to delete.
169+
squad_number (int): The Squad Number of the Player to delete.
168170
169171
Returns:
170172
True if the Player was deleted successfully, False otherwise.
171173
"""
172-
player = await async_session.get(Player, player_id)
174+
player = await retrieve_by_squad_number_async(async_session, squad_number)
173175
await async_session.delete(player)
174176
try:
175177
await async_session.commit()

tests/test_main.py

Lines changed: 28 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
- GET /players/{player_id}
88
- GET /players/squadnumber/{squad_number}
99
- POST /players/
10-
- PUT /players/{player_id}
11-
- DELETE /players/{player_id}
10+
- PUT /players/squadnumber/{squad_number}
11+
- DELETE /players/squadnumber/{squad_number}
1212
1313
Validates:
1414
- Status codes, response bodies, headers (e.g., X-Cache)
@@ -191,66 +191,68 @@ def test_request_post_player_body_nonexistent_response_status_created(client):
191191
assert UUID(body["id"]).version == 4 # UUID v4 (API-created)
192192

193193

194-
# PUT /players/{player_id} -----------------------------------------------------
194+
# PUT /players/squadnumber/{squad_number} --------------------------------------
195195

196196

197-
def test_request_put_player_id_existing_body_empty_response_status_unprocessable(
197+
def test_request_put_player_squadnumber_existing_body_empty_response_status_unprocessable(
198198
client,
199199
):
200-
"""PUT /players/{player_id} with empty body returns 422 Unprocessable Entity"""
200+
"""PUT /players/squadnumber/{squad_number} with empty body returns 422 Unprocessable Entity"""
201201
# Arrange
202-
player_id = existing_player().id
202+
squad_number = existing_player().squad_number
203203
# Act
204-
response = client.put(PATH + str(player_id), json={})
204+
response = client.put(PATH + "squadnumber/" + str(squad_number), json={})
205205
# Assert
206206
assert response.status_code == 422
207207

208208

209-
def test_request_put_player_id_unknown_response_status_not_found(client):
210-
"""PUT /players/{player_id} with unknown ID returns 404 Not Found"""
209+
def test_request_put_player_squadnumber_unknown_response_status_not_found(client):
210+
"""PUT /players/squadnumber/{squad_number} with unknown number returns 404 Not Found"""
211211
# Arrange
212-
player_id = unknown_player().id
212+
squad_number = unknown_player().squad_number
213213
player = unknown_player()
214214
# Act
215-
response = client.put(PATH + str(player_id), json=player.__dict__)
215+
response = client.put(
216+
PATH + "squadnumber/" + str(squad_number), json=player.__dict__
217+
)
216218
# Assert
217219
assert response.status_code == 404
218220

219221

220-
def test_request_put_player_id_existing_response_status_no_content(client):
221-
"""PUT /players/{player_id} with existing ID returns 204 No Content"""
222+
def test_request_put_player_squadnumber_existing_response_status_no_content(client):
223+
"""PUT /players/squadnumber/{squad_number} with existing number returns 204 No Content"""
222224
# Arrange
223-
player_id = existing_player().id
225+
squad_number = existing_player().squad_number
224226
player = existing_player()
225227
player.first_name = "Emiliano"
226228
player.middle_name = ""
227229
# Act
228-
response = client.put(PATH + str(player_id), json=player.__dict__)
230+
response = client.put(
231+
PATH + "squadnumber/" + str(squad_number), json=player.__dict__
232+
)
229233
# Assert
230234
assert response.status_code == 204
231235

232236

233-
# DELETE /players/{player_id} --------------------------------------------------
237+
# DELETE /players/squadnumber/{squad_number} -----------------------------------
234238

235239

236-
def test_request_delete_player_id_unknown_response_status_not_found(client):
237-
"""DELETE /players/{player_id} with unknown ID returns 404 Not Found"""
240+
def test_request_delete_player_squadnumber_unknown_response_status_not_found(client):
241+
"""DELETE /players/squadnumber/{squad_number} with unknown number returns 404 Not Found"""
238242
# Arrange
239-
player_id = unknown_player().id
243+
squad_number = unknown_player().squad_number
240244
# Act
241-
response = client.delete(PATH + str(player_id))
245+
response = client.delete(PATH + "squadnumber/" + str(squad_number))
242246
# Assert
243247
assert response.status_code == 404
244248

245249

246-
def test_request_delete_player_id_existing_response_status_no_content(client):
247-
"""DELETE /players/{player_id} with existing UUID returns 204 No Content"""
248-
# Arrange — create the player to be deleted, then resolve its UUID
250+
def test_request_delete_player_squadnumber_existing_response_status_no_content(client):
251+
"""DELETE /players/squadnumber/{squad_number} with existing number returns 204 No Content"""
252+
# Arrange — create the player to be deleted
249253
player = nonexistent_player()
250254
client.post(PATH, json=player.__dict__)
251-
lookup_response = client.get(PATH + "squadnumber/" + str(player.squad_number))
252-
player_id = lookup_response.json()["id"]
253255
# Act
254-
response = client.delete(PATH + str(player_id))
256+
response = client.delete(PATH + "squadnumber/" + str(player.squad_number))
255257
# Assert
256258
assert response.status_code == 204

0 commit comments

Comments
 (0)