Skip to content

Commit ff47a8c

Browse files
authored
Merge pull request #548 from nanotaboada/feat/rest-polish-530
feat(api): REST polish — cache fix, Location header, 409 detail (#530)
2 parents 94c2115 + 9d1fd90 commit ff47a8c

File tree

5 files changed

+147
-6
lines changed

5 files changed

+147
-6
lines changed

.claude/commands/precommit.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
1+
# Pre-commit checklist
2+
13
Run the pre-commit checklist for this project:
24

3-
1. Remind me to update `CHANGELOG.md` `[Unreleased]` section (Added / Changed / Fixed / Removed) — I must do this manually.
5+
1. Update `CHANGELOG.md` `[Unreleased]` section — add an entry under the
6+
appropriate subsection (Added / Changed / Fixed / Removed) describing the
7+
changes made, referencing the issue number.
48
2. Run `uv run flake8 .` — must pass.
59
3. Run `uv run black --check .` — must pass (run `uv run black .` to auto-fix).
610
4. Run `uv run pytest --cov=./ --cov-report=term` — all tests must pass, coverage must be ≥80%.
711

8-
Run steps 2–4, report the results clearly, then propose a branch name and commit message for my approval using the format `type(scope): description (#issue)` (max 80 chars; types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`). Do not create the branch or commit until I explicitly confirm.
12+
Run steps 1–4, report the results clearly, then propose a branch name and commit message for my approval using the format `type(scope): description (#issue)` (max 80 chars; types: `feat` `fix` `chore` `docs` `test` `refactor` `ci` `perf`). Do not create the branch or commit until I explicitly confirm.

.claude/commands/prerelease.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Pre-release checklist
2+
3+
Run the pre-release checklist for this project.
4+
5+
**Working style**: propose before acting at every step — do not commit, push,
6+
open PRs, or create tags until I explicitly confirm.
7+
8+
---
9+
10+
## Phase 1 — Determine next release
11+
12+
1. **Verify working tree**: Confirm we are on `master` with a clean working
13+
tree. If behind the remote, propose `git pull` and wait for confirmation
14+
before pulling.
15+
16+
2. **Detect current release and propose next**: Read `CHANGELOG.md` for the
17+
coach naming convention (A–Z table) and the most recent release heading.
18+
Run `git tag --sort=-v:refname` to confirm the latest tag. Then:
19+
20+
- **Next codename**: next letter in the A–Z sequence after the current one.
21+
Use lowercase with no spaces for the tag (e.g. `eriksson`);
22+
Title Case for the CHANGELOG heading (e.g. `Eriksson`).
23+
- **Version bump** — infer from `[Unreleased]`:
24+
25+
| Condition | Bump |
26+
|---|---|
27+
| Any entry marked BREAKING | MAJOR |
28+
| Entries under Added | MINOR |
29+
| Only Changed / Fixed / Removed | PATCH |
30+
31+
- If `[Unreleased]` has no entries, stop and warn — there is nothing to release.
32+
33+
Present a summary before proceeding:
34+
35+
```text
36+
Current: v2.1.0-delbosque
37+
Proposed: v2.2.0-eriksson (MINOR — new features in Added)
38+
Branch: release/v2.2.0-eriksson
39+
Tag: v2.2.0-eriksson
40+
```
41+
42+
---
43+
44+
## Phase 2 — Prepare release branch
45+
46+
3. **Create release branch**: `release/vX.Y.Z-{codename}`.
47+
48+
4. **Update CHANGELOG.md**: Move all content under `[Unreleased]` into a new
49+
versioned heading `[X.Y.Z - Codename] - {today's date}`. Replace
50+
`[Unreleased]` with a fresh empty template:
51+
52+
```markdown
53+
## [Unreleased]
54+
55+
### Added
56+
57+
### Changed
58+
59+
### Fixed
60+
61+
### Removed
62+
```
63+
64+
5. **Propose commit**: `docs(changelog): release vX.Y.Z Codename`
65+
66+
6. **After confirmation**: commit. Then run steps 2–4 of `/precommit` (linting,
67+
formatting, tests — the CHANGELOG step is already handled). Push the branch
68+
and open a PR into `master` only once all checks pass.
69+
70+
---
71+
72+
## Phase 3 — Tag and release
73+
74+
7. **Stop and wait** for confirmation that:
75+
- All CI checks have passed
76+
- CodeRabbit review comments have been addressed
77+
- The PR has been merged into `master`
78+
79+
8. **Pull `master`**, then propose the annotated tag:
80+
- Tag name: `vX.Y.Z-{codename}`
81+
- Message: `Release X.Y.Z - Codename`
82+
83+
9. **After confirmation**: create and push the tag. The CD pipeline triggers
84+
automatically.

CHANGELOG.md

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

4343
## [Unreleased]
4444

45+
### Added
46+
47+
### Changed
48+
49+
- `GET /players/` cache check changed from `if not players` to
50+
`if players is None` so that an empty collection is cached correctly
51+
instead of triggering a DB fetch on every request (#530)
52+
- `POST /players/` 409 response now includes a human-readable `detail`
53+
message: "A Player with this squad number already exists." (#530)
54+
55+
### Fixed
56+
57+
- `POST /players/` 201 response now includes a `Location` header
58+
pointing to the created resource at
59+
`/players/squadnumber/{squad_number}` per RFC 7231 §7.1.2 (#530)
60+
61+
### Removed
62+
4563
---
4664

4765
## [2.1.0 - Del Bosque] - 2026-03-31

routes/player_route.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
async def post_async(
5050
player_model: Annotated[PlayerRequestModel, Body(...)],
5151
async_session: Annotated[AsyncSession, Depends(generate_async_session)],
52-
):
52+
response: Response,
53+
) -> PlayerResponseModel:
5354
"""
5455
Endpoint to create a new player.
5556
@@ -68,14 +69,18 @@ async def post_async(
6869
async_session, player_model.squad_number
6970
)
7071
if existing:
71-
raise HTTPException(status_code=status.HTTP_409_CONFLICT)
72+
raise HTTPException(
73+
status_code=status.HTTP_409_CONFLICT,
74+
detail="A Player with this squad number already exists.",
75+
)
7276
player = await player_service.create_async(async_session, player_model)
7377
if player is None: # pragma: no cover
7478
raise HTTPException(
7579
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
7680
detail="Failed to create the Player due to a database error.",
7781
)
7882
await simple_memory_cache.clear(CACHE_KEY)
83+
response.headers["Location"] = f"/players/squadnumber/{player.squad_number}"
7984
return player
8085

8186

@@ -92,7 +97,7 @@ async def post_async(
9297
async def get_all_async(
9398
response: Response,
9499
async_session: Annotated[AsyncSession, Depends(generate_async_session)],
95-
):
100+
) -> List[PlayerResponseModel]:
96101
"""
97102
Endpoint to retrieve all players.
98103
@@ -104,7 +109,7 @@ async def get_all_async(
104109
"""
105110
players = await simple_memory_cache.get(CACHE_KEY)
106111
response.headers["X-Cache"] = "HIT"
107-
if not players:
112+
if players is None:
108113
players = await player_service.retrieve_all_async(async_session)
109114
await simple_memory_cache.set(CACHE_KEY, players, ttl=CACHE_TTL)
110115
response.headers["X-Cache"] = "MISS"

tests/test_main.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,18 @@ def test_request_post_player_body_existing_response_status_conflict(client):
178178
assert response.status_code == 409
179179

180180

181+
def test_request_post_player_body_existing_response_body_detail(client):
182+
"""POST /players/ with existing player returns 409 with detail message"""
183+
# Arrange
184+
player = existing_player()
185+
# Act
186+
response = client.post(PATH, json=player.__dict__)
187+
# Assert
188+
assert (
189+
response.json()["detail"] == "A Player with this squad number already exists."
190+
)
191+
192+
181193
def test_request_post_player_body_nonexistent_response_status_created(client):
182194
"""POST /players/ with nonexistent player returns 201 Created with a valid UUID"""
183195
# Arrange
@@ -275,3 +287,21 @@ def test_request_delete_player_squadnumber_existing_response_status_no_content(c
275287
response = client.delete(PATH + "squadnumber/" + str(player.squad_number))
276288
# Assert
277289
assert response.status_code == 204
290+
291+
292+
def test_request_post_player_body_nonexistent_response_header_location(client):
293+
"""POST /players/ with nonexistent player returns 201 with Location header"""
294+
# Arrange
295+
player = nonexistent_player()
296+
try:
297+
# Act
298+
response = client.post(PATH, json=player.__dict__)
299+
# Assert
300+
assert response.status_code == 201
301+
assert "Location" in response.headers
302+
assert (
303+
response.headers["Location"]
304+
== f"/players/squadnumber/{player.squad_number}"
305+
)
306+
finally:
307+
client.delete(PATH + "squadnumber/" + str(player.squad_number))

0 commit comments

Comments
 (0)