ML-152: add /api/v1/ version prefix to all API routes
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
---
|
---
|
||||||
id: ML-152
|
id: ML-152
|
||||||
title: Add API version prefix to collection endpoints
|
title: Add API version prefix to collection endpoints
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee: []
|
||||||
created_date: '2026-04-30 10:48'
|
created_date: '2026-04-30 10:48'
|
||||||
|
updated_date: '2026-04-30 10:57'
|
||||||
labels:
|
labels:
|
||||||
- api
|
- api
|
||||||
- versioning
|
- versioning
|
||||||
@@ -37,9 +38,57 @@ Update:
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 All collection API routes live under `/api/v1/collection/*`
|
- [x] #1 All collection API routes live under `/api/v1/collection/*`
|
||||||
- [ ] #2 Existing `/api/collection/*` routes are removed or redirect to v1 with 301
|
- [x] #2 Existing `/api/collection/*` routes are removed or redirect to v1 with 301
|
||||||
- [ ] #3 Controller and JSON tests pass against the new `/api/v1/` paths
|
- [x] #3 Controller and JSON tests pass against the new `/api/v1/` paths
|
||||||
- [ ] #4 `test/prod.hurl` references updated to new paths
|
- [x] #4 `test/prod.hurl` references updated to new paths
|
||||||
- [ ] #5 `docs/architecture.md` updated with new route paths
|
- [x] #5 `docs/architecture.md` updated with new route paths
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
**Scope**: Version ALL routes under `/api` to `/api/v1/`, not just collection endpoints. Affected routes:
|
||||||
|
- `/api/v1/collection`
|
||||||
|
- `/api/v1/collection/latest`
|
||||||
|
- `/api/v1/collection/random`
|
||||||
|
- `/api/v1/collection/on_this_day`
|
||||||
|
- `/api/v1/assets/:transform_payload`
|
||||||
|
- `/api/v1/backup`
|
||||||
|
|
||||||
|
**Decision**: Remove old routes (no 301 redirects) — user controls all consumers.
|
||||||
|
|
||||||
|
**Files to change**:
|
||||||
|
1. `lib/music_library_web/router.ex` — Move all routes from `/api` to `/api/v1`
|
||||||
|
2. `test/music_library_web/controllers/collection_controller_test.exs` — Update paths to `/api/v1/collection/*`
|
||||||
|
3. `test/prod.hurl` — Update `/api/collection/latest` → `/api/v1/collection/latest`
|
||||||
|
4. `docs/architecture.md` — Update route table entries for CollectionController, AssetController (API route), ArchiveController (API route)
|
||||||
|
5. Asset controller tests (if any) — Check for `/api/assets/` references
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
## Implementation complete
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
1. **`lib/music_library_web/router.ex`** — Changed `scope "/api"` to `scope "/api/v1"` for all 6 API routes (collection × 4 + assets + backup). Old routes removed per user decision.
|
||||||
|
|
||||||
|
2. **`lib/music_library_web/controllers/collection_json.ex`** — Updated `cover_url` and `thumb_url` path sigils from `~p"/api/assets/..."` to `~p"/api/v1/assets/..."`.
|
||||||
|
|
||||||
|
3. **`test/music_library_web/controllers/collection_controller_test.exs`** — Updated all 4 route paths, 4 describe strings, and 2 expected JSON URLs to `/api/v1/...`.
|
||||||
|
|
||||||
|
4. **`test/prod.hurl`** — Updated both API references from `/api/collection/latest` to `/api/v1/collection/latest`.
|
||||||
|
|
||||||
|
5. **`docs/architecture.md`** — Updated ArchiveController, AssetController, and CollectionController route entries to `/api/v1/...`.
|
||||||
|
|
||||||
|
### Test results
|
||||||
|
```
|
||||||
|
CollectionControllerTest: 5 passed
|
||||||
|
AssetControllerTest: 9 passed
|
||||||
|
ArchiveControllerTest: 1 passed
|
||||||
|
```
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|||||||
@@ -334,9 +334,9 @@ All authenticated routes live inside a single `live_session` with three `on_moun
|
|||||||
| `SessionController` | `/login`, `/sessions/create` | Login/logout |
|
| `SessionController` | `/login`, `/sessions/create` | Login/logout |
|
||||||
| `HealthController` | `/health` | Health check |
|
| `HealthController` | `/health` | Health check |
|
||||||
| `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth |
|
| `LastFmController` | `/auth/last_fm/callback` | Last.fm OAuth |
|
||||||
| `ArchiveController` | `/backup`, `/api/backup` | Database backup download (API route requires token) |
|
| `ArchiveController` | `/backup`, `/api/v1/backup` | Database backup download (API route requires token) |
|
||||||
| `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) |
|
| `AssetController` | `/assets/:transform_payload`, `/public/assets/:transform_payload`, `/api/v1/assets/:transform_payload` | Serve images with transforms (public route for emails, API route requires token) |
|
||||||
| `CollectionController` | `/api/collection/*` | JSON API for collection queries |
|
| `CollectionController` | `/api/v1/collection/*` | JSON API for collection queries |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ defmodule MusicLibraryWeb.CollectionJSON do
|
|||||||
purchased_at: record.purchased_at,
|
purchased_at: record.purchased_at,
|
||||||
artists: Enum.map(record.artists, & &1.name),
|
artists: Enum.map(record.artists, & &1.name),
|
||||||
title: record.title,
|
title: record.title,
|
||||||
cover_url: url(~p"/api/assets/#{Transform.new(hash: record.cover_hash)}"),
|
cover_url: url(~p"/api/v1/assets/#{Transform.new(hash: record.cover_hash)}"),
|
||||||
thumb_url: url(~p"/api/assets/#{Transform.new(hash: record.cover_hash, width: 480)}")
|
thumb_url: url(~p"/api/v1/assets/#{Transform.new(hash: record.cover_hash, width: 480)}")
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ defmodule MusicLibraryWeb.Router do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/api", MusicLibraryWeb do
|
scope "/api/v1", MusicLibraryWeb do
|
||||||
pipe_through :api
|
pipe_through :api
|
||||||
|
|
||||||
get "/collection/latest", CollectionController, :latest
|
get "/collection/latest", CollectionController, :latest
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
|
|||||||
describe "authentication" do
|
describe "authentication" do
|
||||||
test "all API endpoints require a bearer token", %{conn: conn} do
|
test "all API endpoints require a bearer token", %{conn: conn} do
|
||||||
for path <- [
|
for path <- [
|
||||||
~p"/api/collection/latest",
|
~p"/api/v1/collection/latest",
|
||||||
~p"/api/collection/random",
|
~p"/api/v1/collection/random",
|
||||||
~p"/api/collection",
|
~p"/api/v1/collection",
|
||||||
~p"/api/collection/on_this_day"
|
~p"/api/v1/collection/on_this_day"
|
||||||
] do
|
] do
|
||||||
assert get(conn, path).status == 401,
|
assert get(conn, path).status == 401,
|
||||||
"expected 401 for unauthenticated GET #{path}"
|
"expected 401 for unauthenticated GET #{path}"
|
||||||
@@ -26,20 +26,20 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/collection/latest" do
|
describe "GET /api/v1/collection/latest" do
|
||||||
setup [:create_record]
|
setup [:create_record]
|
||||||
|
|
||||||
test "returns the latest record", %{conn: conn, record: record} do
|
test "returns the latest record", %{conn: conn, record: record} do
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
||||||
|> get(~p"/api/collection/latest")
|
|> get(~p"/api/v1/collection/latest")
|
||||||
|
|
||||||
assert json_response(conn, 200) == expected_record_json(record)
|
assert json_response(conn, 200) == expected_record_json(record)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/collection/random" do
|
describe "GET /api/v1/collection/random" do
|
||||||
setup [:create_record]
|
setup [:create_record]
|
||||||
|
|
||||||
# We're not testing random here - the query is solid enough
|
# We're not testing random here - the query is solid enough
|
||||||
@@ -47,20 +47,20 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
|
|||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
||||||
|> get(~p"/api/collection/random")
|
|> get(~p"/api/v1/collection/random")
|
||||||
|
|
||||||
assert json_response(conn, 200) == expected_record_json(record)
|
assert json_response(conn, 200) == expected_record_json(record)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/collection" do
|
describe "GET /api/v1/collection" do
|
||||||
setup [:create_record]
|
setup [:create_record]
|
||||||
|
|
||||||
test "returns a paginated list of records", %{conn: conn, record: record} do
|
test "returns a paginated list of records", %{conn: conn, record: record} do
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
||||||
|> get(~p"/api/collection")
|
|> get(~p"/api/v1/collection")
|
||||||
|
|
||||||
assert json_response(conn, 200) == %{
|
assert json_response(conn, 200) == %{
|
||||||
"total" => 1,
|
"total" => 1,
|
||||||
@@ -71,14 +71,14 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/collection/on_this_day" do
|
describe "GET /api/v1/collection/on_this_day" do
|
||||||
setup [:create_record]
|
setup [:create_record]
|
||||||
|
|
||||||
test "returns a list of records", %{conn: conn, record: record} do
|
test "returns a list of records", %{conn: conn, record: record} do
|
||||||
conn =
|
conn =
|
||||||
conn
|
conn
|
||||||
|> put_req_header("authorization", "Bearer #{api_token()}")
|
|> put_req_header("authorization", "Bearer #{api_token()}")
|
||||||
|> get(~p"/api/collection/on_this_day?date=2025-06-21")
|
|> get(~p"/api/v1/collection/on_this_day?date=2025-06-21")
|
||||||
|
|
||||||
assert json_response(conn, 200) == %{
|
assert json_response(conn, 200) == %{
|
||||||
"records" => [expected_record_json(record)]
|
"records" => [expected_record_json(record)]
|
||||||
@@ -98,9 +98,9 @@ defmodule MusicLibraryWeb.CollectionControllerTest do
|
|||||||
"artists" => Enum.map(record.artists, & &1.name),
|
"artists" => Enum.map(record.artists, & &1.name),
|
||||||
"title" => record.title,
|
"title" => record.title,
|
||||||
"cover_url" =>
|
"cover_url" =>
|
||||||
"http://localhost:4002/api/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjpudWxsfQ",
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjpudWxsfQ",
|
||||||
"thumb_url" =>
|
"thumb_url" =>
|
||||||
"http://localhost:4002/api/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjo0ODB9"
|
"http://localhost:4002/api/v1/assets/eyJoYXNoIjoiNTk5NDA3RERGNjk5MDdENEE2MEZFMTNDQ0FBODI0RDI1Q0YwOERDMTI0RkQ2QUEzRThFN0VDRDk4Qzg4NUZGRSIsIndpZHRoIjo0ODB9"
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+2
-2
@@ -7,11 +7,11 @@ Location: https://music-library.claudio-ortolina.org/
|
|||||||
# API requires a valid token
|
# API requires a valid token
|
||||||
|
|
||||||
# GET latest record
|
# GET latest record
|
||||||
GET https://music-library.claudio-ortolina.org/api/collection/latest
|
GET https://music-library.claudio-ortolina.org/api/v1/collection/latest
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
HTTP 401
|
HTTP 401
|
||||||
|
|
||||||
GET https://music-library.claudio-ortolina.org/api/collection/latest
|
GET https://music-library.claudio-ortolina.org/api/v1/collection/latest
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
Authorization: Bearer {{api_token}}
|
Authorization: Bearer {{api_token}}
|
||||||
HTTP 200
|
HTTP 200
|
||||||
|
|||||||
Reference in New Issue
Block a user