diff --git a/.env.example b/.env.example index 8a7e281..3fa4312 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,10 @@ APP_SECRET=changeme # APP_ALLOW_REGISTRATION=1 # Uncomment to override administration setting DEFAULT_URI=http://localhost:8085 ALBUM_SEARCH_LIMIT=30 # Amount of albums shown on page. Do not set too high, may be rate limited by external APIs. +# API benchmark toggle: +# 1 = keep API image hydration/fallback calls enabled. +# 0 = disable API-side image hydration for raw upstream timing. +API_IMAGE_HYDRATION_ENABLED=1 JWT_PASSPHRASE=changeme DATABASE_DRIVER=sqlite # postgres | sqlite. Untested support for postgres since migration to SQLite. diff --git a/config/services.yaml b/config/services.yaml index e35a2e5..048426e 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,6 +5,7 @@ # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: album_search_limit: '%env(int:ALBUM_SEARCH_LIMIT)%' + api_image_hydration_enabled: '%env(bool:API_IMAGE_HYDRATION_ENABLED)%' services: # default configuration for services in *this* file @@ -60,6 +61,7 @@ services: App\Controller\Api\V1\AlbumApiController: arguments: $searchLimit: '%album_search_limit%' + $apiImageHydrationEnabled: '%api_image_hydration_enabled%' App\EventSubscriber\ApiExceptionSubscriber: arguments: diff --git a/docker-compose.yml b/docker-compose.yml index 61bfea6..a933d4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,7 +6,7 @@ services: dockerfile: docker/php/Dockerfile target: prod args: - APP_ENV: prod + APP_ENV: ${APP_ENV:-prod} container_name: tonehaus restart: unless-stopped volumes: diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 364a335..9592e3e 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -103,7 +103,11 @@ COPY composer.json composer.lock* symfony.lock* ./ # Install vendors (cached) RUN --mount=type=cache,target=/tmp/composer \ if [ -f composer.json ]; then \ - composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \ + if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \ + composer install --prefer-dist --no-interaction --no-progress --no-scripts; \ + else \ + composer install --no-dev --prefer-dist --no-interaction --no-progress --no-scripts; \ + fi; \ fi # Copy the rest of the app @@ -112,7 +116,11 @@ COPY . /var/www/html # Finalize install & warm cache RUN if [ -f bin/console ]; then \ set -ex; \ - composer dump-autoload --no-dev --optimize; \ + if [ "$APP_ENV" = "dev" ] || [ "$APP_ENV" = "test" ]; then \ + composer dump-autoload --optimize; \ + else \ + composer dump-autoload --no-dev --optimize; \ + fi; \ mkdir -p var var/data public/uploads; \ chown -R www-data:www-data var public/uploads; \ fi diff --git a/docker/prod/entrypoint.sh b/docker/prod/entrypoint.sh index 419dd63..d8a38a2 100755 --- a/docker/prod/entrypoint.sh +++ b/docker/prod/entrypoint.sh @@ -2,7 +2,8 @@ set -eu prepare_runtime_dirs() { - mkdir -p var/cache/prod/asset_mapper var/log var/data public/uploads + cache_dir="var/cache/${APP_ENV:-prod}/asset_mapper" + mkdir -p "${cache_dir}" var/log var/data public/uploads chown -R www-data:www-data var public/uploads chmod -R u+rwX,g+rwX var public/uploads @@ -57,11 +58,13 @@ install_runtime() { fi echo "Installing Composer dependencies..." - su-exec www-data composer install \ - --no-dev \ - --prefer-dist \ - --no-interaction \ - --no-progress + composer_flags="--prefer-dist --no-interaction --no-progress" + if [ "${APP_ENV:-prod}" = "prod" ]; then + composer_flags="${composer_flags} --no-dev" + fi + + # shellcheck disable=SC2086 + su-exec www-data composer install ${composer_flags} } install_runtime diff --git a/docs/assignment2-project-briefing.md b/docs/assignment2-project-briefing.md new file mode 100644 index 0000000..3579473 --- /dev/null +++ b/docs/assignment2-project-briefing.md @@ -0,0 +1,969 @@ +# Tonehaus Project Briefing + +## Purpose + +Tonehaus is a Symfony 7 music album review application extended for the Assignment 2 RESTful API brief. It combines a server-rendered website with a versioned JSON API, JWT authentication, external music metadata providers, and generated API documentation. The project is designed so the web UI and API share the same domain model and service layer rather than existing as separate systems. + +## Core Stack + +- Symfony 7 +- PHP with Doctrine ORM +- Twig templates +- SQLite by default, with Postgres support +- Guzzle for external API consumption +- LexikJWTAuthenticationBundle for JWT auth +- FOSRestBundle for API response handling +- NelmioApiDocBundle for OpenAPI documentation +- PHPUnit for functional and unit tests + +## Main Domain Model + +- `User`: authenticated account with `ROLE_USER`, `ROLE_MODERATOR`, or `ROLE_ADMIN` +- `Album`: normalized album record, either external or user-created +- `AlbumTrack`: persisted track list for each album +- `Review`: user-authored review with title, content, rating, and timestamps +- `Setting`: key-value storage for site configuration and provider credentials + +## What The Application Does + +### Web Application + +- lets users search for albums +- shows album pages with cover art, metadata, tracklists, review averages, and reviews +- allows logged-in users to create reviews from the album page +- allows users to create, edit, and delete their own user-created albums +- provides profile, password, and settings pages +- gives moderators/admins dashboards for moderation and site administration +- includes admin settings for external provider credentials and registration control + +### REST API + +The API is versioned under `/api/v1` and returns JSON. + +Implemented endpoints: + +- `GET /api/v1` +- `POST /api/v1/auth/login` +- `GET /api/v1/albums` +- `POST /api/v1/albums/ingest` +- `GET /api/v1/albums/{id}` +- `GET /api/v1/albums/{id}/reviews` +- `GET /api/v1/reviews` +- `GET /api/v1/reviews/{id}` +- `POST /api/v1/reviews` +- `PUT /api/v1/reviews/{id}` +- `PATCH /api/v1/reviews/{id}` +- `DELETE /api/v1/reviews/{id}` + +Automatic OpenAPI documentation is available at: + +- `/api/docs` +- `/api/v1/docs` +- `/api/docs.json` +- `/api/v1/docs.json` + +## API Endpoint Reference + +This section documents every implemented API endpoint briefly, with purpose, authentication, key inputs, and the main response shape. + +Example values below are representative of the current implementation and use realistic sample IDs such as `tidal:251380836` and numeric review IDs. + +### `GET /api/v1` + +- purpose: API index and discovery endpoint +- auth: none +- returns: API name, version, endpoint groups, top-level `links`, and top-level `actions` +- HATEOAS: exposes `self`, `auth_login`, `albums`, `reviews`, `docs`, `docs_v1`, `schema`, and `schema_v1` + +Example request: + +```http +GET /api/v1 +Accept: application/json +``` + +Example response: + +```json +{ + "name": "Tonehaus API", + "version": "v1", + "endpoints": { + "auth": ["POST /api/v1/auth/login"], + "albums": [ + "GET /api/v1/albums", + "POST /api/v1/albums/ingest (JWT)", + "GET /api/v1/albums/{id}", + "GET /api/v1/albums/{id}/reviews" + ], + "reviews": [ + "GET /api/v1/reviews", + "GET /api/v1/reviews/{id}", + "POST /api/v1/reviews (JWT)", + "PUT /api/v1/reviews/{id} (JWT)", + "PATCH /api/v1/reviews/{id} (JWT)", + "DELETE /api/v1/reviews/{id} (JWT)" + ] + }, + "links": { + "self": "/api/v1", + "auth_login": "/api/v1/auth/login", + "albums": "/api/v1/albums", + "reviews": "/api/v1/reviews", + "docs": "/api/docs" + }, + "actions": { + "login": { + "method": "POST", + "href": "/api/v1/auth/login" + } + } +} +``` + +### `POST /api/v1/auth/login` + +- purpose: authenticate a user and issue a JWT for protected routes +- auth: none +- request body: `email`, `password` +- success response: `200` with `{ "token": "..." }` +- error response: `401` for invalid credentials + +Example request: + +```http +POST /api/v1/auth/login +Content-Type: application/json +Accept: application/json + +{ + "email": "user@example.com", + "password": "password123" +} +``` + +Example success response: + +```json +{ + "token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9..." +} +``` + +Example error response: + +```json +{ + "error": { + "code": "invalid_credentials", + "message": "Invalid credentials.", + "status": 401 + } +} +``` + +### `GET /api/v1/albums` + +- purpose: search albums from the local catalog and the currently active external provider +- auth: none +- query parameters: `query`, `album`, `artist`, `genre`, `year_from`, `year_to`, `source`, `limit`, `mode` +- returns: + - `meta` with source, limit, query, mode, and search profile + - `data` as album resources + - `stats` for review aggregates + - `saved_ids` for already-ingested external albums + - collection-level `links` and `actions` +- HATEOAS: + - collection links include `self`, `root`, and `ingest` + - action metadata includes `ingest_album` + - each album resource is decorated with its own `links` and `actions` + +Example request: + +```http +GET /api/v1/albums?query=4%3A44&artist=JAY-Z&limit=5&mode=fast +Accept: application/json +``` + +Example response: + +```json +{ + "meta": { + "source": "all", + "limit": 5, + "query": "4:44", + "mode": "fast" + }, + "data": [ + { + "id": "tidal:251380836", + "name": "4:44", + "images": [ + { "url": "https://cdn.example.test/320.jpg" } + ], + "artists": [ + { "name": "JAY-Z" } + ], + "genres": ["Hip Hop"], + "release_date": "2017-06-30", + "total_tracks": 10, + "external_urls": { + "external": "https://tidal.com/browse/album/251380836" + }, + "source": "external", + "links": { + "self": "/api/v1/albums/tidal%3A251380836", + "reviews": "/api/v1/albums/tidal%3A251380836/reviews", + "open_source": "https://tidal.com/browse/album/251380836" + }, + "actions": { + "create_review": { + "method": "POST", + "href": "/api/v1/reviews", + "requires_auth": true, + "payload": { + "album_id": "tidal:251380836" + } + }, + "ingest": { + "method": "POST", + "href": "/api/v1/albums/ingest", + "requires_auth": true, + "payload": { + "external_id": "tidal:251380836" + } + } + } + } + ], + "stats": { + "tidal:251380836": { + "count": 2, + "avg": 8.5 + } + }, + "saved_ids": ["tidal:251380836"], + "links": { + "self": "/api/v1/albums?source=all&limit=5&mode=fast&query=4%3A44&artist=JAY-Z", + "root": "/api/v1", + "ingest": "/api/v1/albums/ingest" + }, + "actions": { + "ingest_album": { + "method": "POST", + "href": "/api/v1/albums/ingest", + "requires_auth": true + } + } +} +``` + +### `POST /api/v1/albums/ingest` + +- purpose: persist an external album into the local database +- auth: JWT required +- request body: + - either `external_id` + - or `query` + - or `artist` and `album` +- success response: `201` with `data` for the ingested album and `tracks` +- main error responses: + - `400` invalid JSON or missing lookup input + - `401` missing/invalid JWT + - `404` external album not found + - `422` album missing required metadata or tracks + +Example request: + +```http +POST /api/v1/albums/ingest +Authorization: Bearer +Content-Type: application/json +Accept: application/json + +{ + "external_id": "tidal:251380836" +} +``` + +Example response: + +```json +{ + "data": { + "id": "tidal:251380836", + "name": "4:44", + "artists": [ + { "name": "JAY-Z" } + ], + "genres": ["Hip Hop"], + "release_date": "2017-06-30", + "total_tracks": 10, + "source": "external", + "links": { + "self": "/api/v1/albums/tidal%3A251380836", + "reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + }, + "tracks": [ + { + "id": "track-1", + "disc": 1, + "track": 1, + "name": "Kill Jay Z", + "duration_ms": 205000 + } + ] +} +``` + +### `GET /api/v1/albums/{id}` + +- purpose: fetch one album by public identifier +- auth: none +- path parameter: `id` string public album ID such as a provider ID or user album ID +- returns: + - `data` for one album + - `tracks` list + - `stats` with review count and average rating +- behaviour: + - if the album is not yet local but is available from the external provider, it can be hydrated and persisted automatically +- HATEOAS on `data`: + - links include `self`, `reviews`, and `open_source` when available + - actions include `create_review` + - external albums also expose `ingest` + +Example request: + +```http +GET /api/v1/albums/tidal%3A251380836 +Accept: application/json +``` + +Example response: + +```json +{ + "data": { + "id": "tidal:251380836", + "name": "4:44", + "images": [ + { "url": "https://cdn.example.test/640.jpg" } + ], + "artists": [ + { "name": "JAY-Z" } + ], + "genres": ["Hip Hop"], + "release_date": "2017-06-30", + "total_tracks": 10, + "external_urls": { + "external": "https://tidal.com/browse/album/251380836" + }, + "source": "external", + "links": { + "self": "/api/v1/albums/tidal%3A251380836", + "reviews": "/api/v1/albums/tidal%3A251380836/reviews", + "open_source": "https://tidal.com/browse/album/251380836" + }, + "actions": { + "create_review": { + "method": "POST", + "href": "/api/v1/reviews", + "requires_auth": true, + "payload": { + "album_id": "tidal:251380836" + } + }, + "ingest": { + "method": "POST", + "href": "/api/v1/albums/ingest", + "requires_auth": true, + "payload": { + "external_id": "tidal:251380836" + } + } + } + }, + "tracks": [ + { + "id": "track-1", + "disc": 1, + "track": 1, + "name": "Kill Jay Z", + "duration_ms": 205000 + } + ], + "stats": { + "count": 2, + "avg": 8.5 + } +} +``` + +### `GET /api/v1/albums/{id}/reviews` + +- purpose: list reviews for a specific album +- auth: none +- path parameter: `id` string public album ID +- returns: + - `data` as review resources scoped to that album + - collection `links` + - collection `actions` +- HATEOAS: + - collection links include `self`, `album`, and `reviews` + - collection actions include `create_review` + - each review item includes links back to its own item route and related album routes + +Example request: + +```http +GET /api/v1/albums/tidal%3A251380836/reviews +Accept: application/json +``` + +Example response: + +```json +{ + "data": [ + { + "id": 12, + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 9, + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T11:40:21+00:00", + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } + ], + "links": { + "self": "/api/v1/albums/tidal%3A251380836/reviews", + "album": "/api/v1/albums/tidal%3A251380836", + "reviews": "/api/v1/reviews" + }, + "actions": { + "create_review": { + "method": "POST", + "href": "/api/v1/reviews", + "requires_auth": true + } + } +} +``` + +### `GET /api/v1/reviews` + +- purpose: list the latest reviews +- auth: none +- query parameters: `limit` from `1` to `100`, default `50` +- returns: + - `data` as review resources + - collection `links` + - collection `actions` +- HATEOAS: + - collection links include `self`, `root`, and `create` + - collection actions include `create_review` + +Example request: + +```http +GET /api/v1/reviews?limit=10 +Accept: application/json +``` + +Example response: + +```json +{ + "data": [ + { + "id": 12, + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 9, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T11:40:21+00:00", + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "album": { + "id": "tidal:251380836", + "name": "4:44", + "source": "external" + }, + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } + ], + "links": { + "self": "/api/v1/reviews?limit=10", + "root": "/api/v1", + "create": "/api/v1/reviews" + }, + "actions": { + "create_review": { + "method": "POST", + "href": "/api/v1/reviews", + "requires_auth": true + } + } +} +``` + +### `GET /api/v1/reviews/{id}` + +- purpose: fetch one review by numeric ID +- auth: none +- path parameter: `id` integer review ID +- returns: one review resource in `data` +- HATEOAS on the review resource: + - links include `self`, `album`, and `album_reviews` + - if the current user is allowed to change the review, action metadata can include `update` and `delete` + +Example request: + +```http +GET /api/v1/reviews/12 +Accept: application/json +``` + +Example response: + +```json +{ + "data": { + "id": 12, + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 9, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T11:40:21+00:00", + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "album": { + "id": "tidal:251380836", + "name": "4:44", + "source": "external" + }, + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } +} +``` + +### `POST /api/v1/reviews` + +- purpose: create a new review for an existing album +- auth: JWT required +- request body: + - `album_id` + - `title` + - `content` + - `rating` +- validation rules: + - `title` required + - `content` required and minimum 20 characters + - `rating` must be between 1 and 10 + - `album_id` must resolve to a valid album with tracks +- success response: `201` with the created review resource +- main error responses: + - `400` invalid JSON + - `401` authentication required + - `404` album not found + - `422` validation failure + +Example request: + +```http +POST /api/v1/reviews +Authorization: Bearer +Content-Type: application/json +Accept: application/json + +{ + "album_id": "tidal:251380836", + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 9 +} +``` + +Example response: + +```json +{ + "data": { + "id": 12, + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 9, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T11:40:21+00:00", + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "album": { + "id": "tidal:251380836", + "name": "4:44", + "source": "external" + }, + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } +} +``` + +### `PUT /api/v1/reviews/{id}` + +- purpose: update an existing review +- auth: JWT required +- path parameter: `id` integer review ID +- accepted body fields: `title`, `content`, `rating` +- authorization: review author, moderator, or admin +- success response: `200` with updated review resource +- main error responses: + - `400` invalid JSON + - `401` missing/invalid JWT + - `403` forbidden + - `422` validation failure +- implementation note: `PUT` currently shares the same update handler as `PATCH` + +Example request: + +```http +PUT /api/v1/reviews/12 +Authorization: Bearer +Content-Type: application/json +Accept: application/json + +{ + "title": "Revisited after a week", + "content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.", + "rating": 8 +} +``` + +Example response: + +```json +{ + "data": { + "id": 12, + "title": "Revisited after a week", + "content": "The writing is sharper than I first thought and the production has a lot more depth on repeat listens.", + "rating": 8, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T12:05:44+00:00", + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "album": { + "id": "tidal:251380836", + "name": "4:44", + "source": "external" + }, + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } +} +``` + +### `PATCH /api/v1/reviews/{id}` + +- purpose: partially update an existing review +- auth: JWT required +- path parameter: `id` integer review ID +- accepted body fields: `title`, `content`, `rating` +- authorization: review author, moderator, or admin +- success response: `200` with updated review resource +- main error responses: + - `400` invalid JSON + - `401` missing/invalid JWT + - `403` forbidden + - `422` validation failure + +Example request: + +```http +PATCH /api/v1/reviews/12 +Authorization: Bearer +Content-Type: application/json +Accept: application/json + +{ + "rating": 10 +} +``` + +Example response: + +```json +{ + "data": { + "id": 12, + "title": "A dense and rewarding record", + "content": "This album becomes stronger on every listen and the production is excellent.", + "rating": 10, + "created_at": "2026-04-02T11:40:21+00:00", + "updated_at": "2026-04-02T12:10:03+00:00", + "author": { + "id": 4, + "display_name": "Jake Andrews" + }, + "album": { + "id": "tidal:251380836", + "name": "4:44", + "source": "external" + }, + "links": { + "self": "/api/v1/reviews/12", + "album": "/api/v1/albums/tidal%3A251380836", + "album_reviews": "/api/v1/albums/tidal%3A251380836/reviews" + } + } +} +``` + +### `DELETE /api/v1/reviews/{id}` + +- purpose: delete a review +- auth: JWT required +- path parameter: `id` integer review ID +- authorization: review author, moderator, or admin +- success response: `204 No Content` +- main error responses: + - `401` missing/invalid JWT + - `403` forbidden + - `404` not found + +Example request: + +```http +DELETE /api/v1/reviews/12 +Authorization: Bearer +Accept: application/json +``` + +Example response: + +```http +204 No Content +``` + +## Architecture Summary + +The project follows standard Symfony separation of concerns. + +- controllers handle HTTP orchestration only +- services contain business logic and external API integration +- repositories handle domain-specific queries +- entities define persisted state +- forms handle server-side validation for web submissions +- security config and voters enforce route access and ownership rules +- event subscribers normalize API errors into JSON responses + +This matters for the brief because the API is not implemented as one large controller. It is structured in a maintainable way using framework conventions and reusable services. + +## API Implementation Summary + +The API implementation is centered around resource-based routes rather than action-style routes. Review endpoints expose collection and item resources, while album endpoints expose search, detail, ingestion, and album-specific review resources. + +Key implementation points: + +- uses descriptive REST-style URIs such as `/api/v1/reviews` and `/api/v1/albums/{id}/reviews` +- uses correct HTTP verbs for retrieval, creation, update, and deletion +- returns JSON for both success and failure cases +- includes HATEOAS-style hypermedia in API responses through `links` and `actions` +- uses JWT authentication for protected write operations +- excludes sensitive data such as user email and password hashes from API responses +- uses structured status codes including `200`, `201`, `204`, `401`, `403`, `404`, and `422` +- provides generated OpenAPI documentation through Nelmio + +The review API supports full CRUD behaviour for third-party clients. Public reads are available without authentication, while create, update, and delete require authenticated users. Authorization is more specific than simple login checks because review ownership is enforced with a `ReviewVoter`, allowing moderators and admins to manage any review while normal users can only manage their own. + +The API also includes a useful sub-resource: + +- `GET /api/v1/albums/{id}/reviews` + +This improves the resource model because reviews can be queried globally or in the context of a specific album. + +The API also now includes a lightweight HATEOAS layer. The base API index exposes navigational links such as `self`, `albums`, `reviews`, and `docs`, plus action metadata for login. Album and review resources expose their own `links` and, where relevant, `actions`, for example: + +- album resources link to `self`, `reviews`, and `open_source` +- album resources expose actions such as `create_review` and `ingest` +- review resources link back to their own item route and related album routes +- review collections expose a `create_review` action for discoverability + +This improves the API from a client perspective because consumers do not need to hard-code every related route manually. Instead, the response itself advertises relevant next operations. + +## API Consumption Summary + +The application consumes external music APIs through Guzzle and a provider abstraction rather than binding the UI directly to one upstream service. + +Implemented external providers: + +- Last.fm +- MusicBrainz +- TIDAL + +Provider logic is normalized behind `ExternalAlbumClientInterface`, with concrete clients for each provider. This means the rest of the application receives one consistent album payload structure regardless of the source. + +The consumed API data is used to: + +- search external album catalogs +- populate album metadata +- fetch artists, genres, release dates, and track lists +- fetch cover art +- ingest external albums into the local database +- enrich album detail pages and search results in the UI + +This is deep integration rather than a token demo call. External data directly affects the search experience, album display, save/ingest flow, and review context. + +## External Integration Details + +### Last.fm + +Used for album search and metadata enrichment. Responses are cached and normalized into the project’s album format. + +### MusicBrainz + +Used as an alternative provider and for structured release metadata. The project supports optional authenticated requests and cover-art URL generation via normalized output. + +### TIDAL + +Used as another full external provider. The `TidalClient` implements OAuth 2.0 client credentials, caches access tokens, retries on expired auth, and converts JSON:API responses into the same normalized album contract used elsewhere. + +This strengthens the project against the brief because it goes beyond a single trivial API call and includes a second non-trivial external integration path. + +## Security Summary + +Security is handled at multiple layers: + +- JWT authentication for protected API routes +- Symfony access control rules for route-level protection +- role hierarchy: admin > moderator > user +- `ReviewVoter` for ownership-based edit/delete permissions +- normalized JSON authentication failures for API clients +- exclusion of sensitive user fields from serialized API responses + +This is stronger than basic coursework authentication because it combines framework security with resource-level authorization. + +## Error Handling and Robustness + +API error handling is centralized so clients receive JSON errors rather than HTML exception pages. + +The project includes: + +- a shared API response helper +- exception subscribers that normalize failures +- JWT failure subscribers for missing, invalid, or expired tokens +- validation logic for malformed JSON and invalid field values +- server-side handling for missing albums, forbidden edits, and unsupported methods + +This improves client usability and supports a more production-like API design. + +## Testing Summary + +The project includes both functional and unit tests. + +Functional API tests cover: + +- missing JWT +- invalid JWT +- malformed JSON +- validation failures +- forbidden review updates +- missing album resources +- protection of sensitive author data +- hypermedia links and action metadata on API index, album, and album-review responses + +Unit tests cover provider-specific service behaviour, including TIDAL payload normalization and provider routing. + +This supports the brief’s emphasis on working, demonstrable features rather than partially implemented code. + +## How The Project Meets The Brief + +### API Implementation + +The brief requires a usable RESTful API capable of listing, reading, creating, editing, and deleting album reviews in JSON. This project meets that with: + +- `GET`, `POST`, `PUT`, `PATCH`, and `DELETE` review endpoints +- JWT-protected write routes +- appropriate resource names +- JSON request and response formats +- HATEOAS-style `links` and `actions` to improve discoverability +- status-code-based error handling +- sub-resource support through album review listing +- automatically generated OpenAPI documentation + +### API Consumption + +The brief requires at least two significantly different examples of API interaction and integration into the review website. This project meets that with: + +- external album search +- album metadata retrieval +- track list retrieval +- cover art integration +- album ingestion into local storage +- multiple provider implementations + +The consumed data is visible in the website itself, especially on search results and album detail pages. + +### Documentation and Report Potential + +The project already has strong source material for the report because it includes: + +- generated OpenAPI docs +- explicit controller routes and OpenAPI attributes +- centralized security configuration +- reusable external service classes +- tests that demonstrate expected API behaviour + +## Honest Technical Caveats + +- `PUT` and `PATCH` currently share the same update action, so `PUT` behaves more like a flexible update than a strict full replacement +- local verification in this shell was limited because `php` is not installed here, so PHPUnit was not run live during this session +- some observability headers in album search are still named with `Lastfm` even though the provider layer now supports multiple providers + +These are not major blockers, but they are worth understanding if you want the final report to sound precise and credible. + +## Strongest Points To Emphasize In A Final Report + +- the API is versioned and resource-oriented +- the API now includes lightweight HATEOAS support through response `links` and `actions` +- write operations are protected with JWT rather than session auth +- review ownership is enforced through a voter, not just a login check +- external APIs are abstracted behind a provider interface +- external data is deeply integrated into the UI and persisted catalog +- OpenAPI documentation is generated automatically +- the project includes automated tests for important API behaviour + +## Short Project Summary + +Tonehaus is a Symfony-based music review platform extended into a RESTful web application. It implements a versioned JSON API for review CRUD and album access, secures write routes with JWT, consumes multiple external music providers through Guzzle and a normalized service layer, and integrates that data directly into album discovery, metadata display, tracklists, and review workflows. The architecture is modular, test-backed, and aligned with the main goals of the Assignment 2 brief. diff --git a/src/Controller/Api/V1/AlbumApiController.php b/src/Controller/Api/V1/AlbumApiController.php index d0bed03..11790a1 100644 --- a/src/Controller/Api/V1/AlbumApiController.php +++ b/src/Controller/Api/V1/AlbumApiController.php @@ -30,6 +30,7 @@ class AlbumApiController extends ApiController private readonly LastFmTagResolver $tagResolver, private readonly EntityManagerInterface $entityManager, private readonly int $searchLimit = 20, + private readonly bool $apiImageHydrationEnabled = true, ) { } @@ -62,7 +63,11 @@ class AlbumApiController extends ApiController { $criteria = AlbumSearchCriteria::fromRequest($request, $this->searchLimit); $searchMode = $this->resolveSearchMode($request); - $result = $this->albumSearchService->search($criteria, $searchMode === 'fast'); + $result = $this->albumSearchService->search( + $criteria, + $searchMode === 'fast', + $this->apiImageHydrationEnabled + ); $profile = $this->albumSearchService->getLastSearchProfile(); $queryParameters = $this->buildAlbumIndexQueryParameters($criteria, $searchMode); $albums = array_map( @@ -95,6 +100,7 @@ class AlbumApiController extends ApiController ], ], Response::HTTP_OK); $this->attachSearchProfileHeaders($response, $profile); + $response->headers->set('X-Tonehaus-Api-Image-Hydration', $this->apiImageHydrationEnabled ? '1' : '0'); return $response; } @@ -182,7 +188,10 @@ class AlbumApiController extends ApiController $album = $this->persistExternalAlbumPayload($externalAlbum); $this->entityManager->flush(); - $payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album, $externalAlbum); + $payload = $album->toTemplateArray(); + if ($this->apiImageHydrationEnabled) { + $payload = $this->hydrateExternalAlbumImages($payload, $album, $externalAlbum); + } return $this->apiResponse([ 'data' => $this->decorateAlbumResource($payload, $album), @@ -232,7 +241,10 @@ class AlbumApiController extends ApiController $albumId = (string) $album->getExternalId(); $stats = $albumId !== '' ? $this->reviewRepository->getAggregatesForAlbumIds([$albumId]) : []; - $payload = $this->hydrateExternalAlbumImages($album->toTemplateArray(), $album); + $payload = $album->toTemplateArray(); + if ($this->apiImageHydrationEnabled) { + $payload = $this->hydrateExternalAlbumImages($payload, $album); + } return $this->apiResponse([ 'data' => $this->decorateAlbumResource($payload, $album), diff --git a/src/Service/AlbumSearchService.php b/src/Service/AlbumSearchService.php index ce17d89..9e11fcc 100644 --- a/src/Service/AlbumSearchService.php +++ b/src/Service/AlbumSearchService.php @@ -37,12 +37,16 @@ class AlbumSearchService /** * Runs the end-to-end search flow and returns review stats plus saved IDs. */ - public function search(AlbumSearchCriteria $criteria, bool $preferFastExternal = false): AlbumSearchResult + public function search( + AlbumSearchCriteria $criteria, + bool $preferFastExternal = false, + bool $hydrateExternalImages = true + ): AlbumSearchResult { $searchStartedAt = microtime(true); $externalQuery = $this->buildExternalSearchQuery($criteria); $hasUserFilters = $this->hasUserFilters($criteria, $externalQuery); - $this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal); + $this->resetSearchProfile($criteria, $externalQuery, $preferFastExternal, $hydrateExternalImages); $shouldQueryExternal = $criteria->shouldUseExternal() && ( @@ -60,7 +64,12 @@ class AlbumSearchService if ($shouldQueryExternal) { $externalStartedAt = microtime(true); - $externalData = $this->resolveExternalAlbums($criteria, $externalQuery, $preferFastExternal); + $externalData = $this->resolveExternalAlbums( + $criteria, + $externalQuery, + $preferFastExternal, + $hydrateExternalImages + ); $externalPayloads = $externalData['payloads']; $stats = $this->mergeStats($stats, $externalData['stats']); $savedIds = $this->mergeSavedIds($savedIds, $externalData['savedIds']); @@ -105,9 +114,13 @@ class AlbumSearchService private function buildExternalSearchQuery(AlbumSearchCriteria $criteria): string { - if ($this->externalClient->getProviderName() === 'musicbrainz') { + $providerName = $this->externalClient->getProviderName(); + if ($providerName === 'musicbrainz') { return $this->buildMusicBrainzSearchQuery($criteria); } + if ($providerName === 'tidal') { + return $this->buildTidalSearchQuery($criteria); + } $parts = []; if ($criteria->query !== '') { @@ -134,6 +147,37 @@ class AlbumSearchService return trim(implode(' ', $parts)); } + private function buildTidalSearchQuery(AlbumSearchCriteria $criteria): string + { + $freeText = trim($criteria->query); + if ($freeText !== '') { + // Keep TIDAL search rooted in the main free-text query. + return $freeText; + } + + $fallbackParts = []; + if ($criteria->albumName !== '') { + $fallbackParts[] = $criteria->albumName; + } + if ($criteria->artist !== '') { + $fallbackParts[] = $criteria->artist; + } + if ($fallbackParts !== []) { + return trim(implode(' ', $fallbackParts)); + } + if ($criteria->genre !== '') { + return $criteria->genre; + } + if ($criteria->yearFrom !== null) { + return (string) $criteria->yearFrom; + } + if ($criteria->yearTo !== null) { + return (string) $criteria->yearTo; + } + + return ''; + } + private function buildMusicBrainzSearchQuery(AlbumSearchCriteria $criteria): string { $clauses = []; @@ -198,7 +242,12 @@ class AlbumSearchService /** * @return array{payloads:array>,stats:array,savedIds:array} */ - private function resolveExternalAlbums(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): array + private function resolveExternalAlbums( + AlbumSearchCriteria $criteria, + string $externalQuery, + bool $preferFastExternal, + bool $hydrateExternalImages + ): array { $providerName = $this->externalClient->getProviderName(); $skipGenrePostFilter = $providerName === 'musicbrainz' && $criteria->genre !== ''; @@ -217,6 +266,7 @@ class AlbumSearchService $storedPayloads = array_map(static fn(Album $album) => $album->toTemplateArray(), $storedExternalAlbums); $storedPayloads = $this->filterPayloadsByTrackCount($storedPayloads); + $storedPayloads = $this->filterPayloadsByAlbumName($storedPayloads, $criteria->albumName); $storedPayloads = $this->filterPayloadsByArtist($storedPayloads, $criteria->artist); if (!$skipGenrePostFilter) { $storedPayloads = $this->filterPayloadsByGenre($storedPayloads, $criteria->genre); @@ -231,7 +281,9 @@ class AlbumSearchService if (!$shouldFetchFromExternal) { $limitedStoredPayloads = array_slice($storedPayloads, 0, $criteria->limit); return [ - 'payloads' => $this->hydrateExternalImages($limitedStoredPayloads), + 'payloads' => $hydrateExternalImages + ? $this->hydrateExternalImages($limitedStoredPayloads) + : $limitedStoredPayloads, 'stats' => $stats, 'savedIds' => $storedIds, ]; @@ -239,9 +291,10 @@ class AlbumSearchService $useSearchOnlyFastPath = $preferFastExternal && !$this->requiresDetailedExternalFetch($criteria); $apiPayloads = $useSearchOnlyFastPath - ? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery) - : $this->fetchExternalPayloads($criteria, $externalQuery); - $filteredApiPayloads = $this->filterPayloadsByArtist($apiPayloads['payloads'], $criteria->artist); + ? $this->fetchExternalPayloadsSearchOnly($criteria, $externalQuery, $skipGenrePostFilter) + : $this->fetchExternalPayloads($criteria, $externalQuery, $skipGenrePostFilter); + $filteredApiPayloads = $this->filterPayloadsByAlbumName($apiPayloads['payloads'], $criteria->albumName); + $filteredApiPayloads = $this->filterPayloadsByArtist($filteredApiPayloads, $criteria->artist); if (!$skipGenrePostFilter) { $filteredApiPayloads = $this->filterPayloadsByGenre($filteredApiPayloads, $criteria->genre); } @@ -249,7 +302,7 @@ class AlbumSearchService $mergedPayloads = $this->mergePayloadLists($filteredApiPayloads, $storedPayloads, $criteria->limit); return [ - 'payloads' => $this->hydrateExternalImages($mergedPayloads), + 'payloads' => $hydrateExternalImages ? $this->hydrateExternalImages($mergedPayloads) : $mergedPayloads, 'stats' => $this->mergeStats($stats, $apiPayloads['stats']), 'savedIds' => $this->mergeSavedIds($storedIds, $apiPayloads['savedIds']), ]; @@ -260,7 +313,11 @@ class AlbumSearchService * * @return array{payloads:array>,stats:array,savedIds:array} */ - private function fetchExternalPayloadsSearchOnly(AlbumSearchCriteria $criteria, string $externalQuery): array + private function fetchExternalPayloadsSearchOnly( + AlbumSearchCriteria $criteria, + string $externalQuery, + bool $skipGenreFilter = false + ): array { $perPage = max($criteria->limit, 10); $seenIds = []; @@ -286,6 +343,9 @@ class AlbumSearchService if ($id === '' || isset($seenIds[$id])) { continue; } + if (!$this->payloadMatchesCriteria($searchItem, $criteria, $skipGenreFilter)) { + continue; + } $seenIds[$id] = true; $payloads[] = $searchItem; @@ -308,10 +368,14 @@ class AlbumSearchService /** * @return array{payloads:array>,stats:array,savedIds:array} */ - private function fetchExternalPayloads(AlbumSearchCriteria $criteria, string $externalQuery): array + private function fetchExternalPayloads( + AlbumSearchCriteria $criteria, + string $externalQuery, + bool $skipGenreFilter = false + ): array { if (!$this->requiresDetailedExternalFetch($criteria)) { - return $this->fetchExternalPayloadsLight($criteria, $externalQuery); + return $this->fetchExternalPayloadsLight($criteria, $externalQuery, $skipGenreFilter); } $perPage = max($criteria->limit, 10); @@ -363,6 +427,9 @@ class AlbumSearchService if ($albumId === '' || !$this->isIngestableExternalAlbum((array) $payload)) { continue; } + if (!$this->payloadMatchesCriteria((array) $payload, $criteria, $skipGenreFilter)) { + continue; + } $this->albumRepository->upsertFromExternalAlbum( (array) $payload, @@ -427,7 +494,11 @@ class AlbumSearchService * * @return array{payloads:array>,stats:array,savedIds:array} */ - private function fetchExternalPayloadsLight(AlbumSearchCriteria $criteria, string $externalQuery): array + private function fetchExternalPayloadsLight( + AlbumSearchCriteria $criteria, + string $externalQuery, + bool $skipGenreFilter = false + ): array { $perPage = max($criteria->limit, 10); $maxCandidateIdsPerPage = max($criteria->limit + self::VALIDATION_ID_HEADROOM, $criteria->limit); @@ -496,6 +567,9 @@ class AlbumSearchService if ($externalId === '') { continue; } + if (!$this->payloadMatchesCriteria($albumPayload, $criteria, $skipGenreFilter)) { + continue; + } $this->albumRepository->upsertFromExternalAlbum( $albumPayload, @@ -786,6 +860,23 @@ class AlbumSearchService })); } + /** + * @param array> $payloads + * @return array> + */ + private function filterPayloadsByAlbumName(array $payloads, string $albumNeedle): array + { + $needle = trim(mb_strtolower($albumNeedle)); + if ($needle === '') { + return $payloads; + } + + return array_values(array_filter($payloads, static function (array $payload) use ($needle): bool { + $name = mb_strtolower(trim((string) ($payload['name'] ?? ''))); + return $name !== '' && str_contains($name, $needle); + })); + } + /** * @param array> $payloads * @return array> @@ -855,6 +946,22 @@ class AlbumSearchService return array_values(array_filter($payloads, fn(array $payload): bool => $this->hasAtLeastOneTrack($payload))); } + /** + * @param array $payload + */ + private function payloadMatchesCriteria(array $payload, AlbumSearchCriteria $criteria, bool $skipGenreFilter): bool + { + $candidates = [$payload]; + $candidates = $this->filterPayloadsByAlbumName($candidates, $criteria->albumName); + $candidates = $this->filterPayloadsByArtist($candidates, $criteria->artist); + if (!$skipGenreFilter) { + $candidates = $this->filterPayloadsByGenre($candidates, $criteria->genre); + } + $candidates = $this->filterPayloadsByYear($candidates, $criteria->yearFrom, $criteria->yearTo); + + return $candidates !== []; + } + /** * @param array> $payloads * @return array> @@ -936,7 +1043,12 @@ class AlbumSearchService || $criteria->yearTo !== null; } - private function resetSearchProfile(AlbumSearchCriteria $criteria, string $externalQuery, bool $preferFastExternal): void + private function resetSearchProfile( + AlbumSearchCriteria $criteria, + string $externalQuery, + bool $preferFastExternal, + bool $hydrateExternalImages + ): void { $this->lastSearchProfile = [ 'query' => $externalQuery, @@ -944,6 +1056,7 @@ class AlbumSearchService 'limit' => $criteria->limit, 'external_provider' => $this->externalClient->getProviderName(), 'prefer_fast_external' => $preferFastExternal, + 'image_hydration_enabled' => $hydrateExternalImages, 'timings_ms' => [ 'total' => 0.0, 'external' => 0.0, diff --git a/src/Service/TidalClient.php b/src/Service/TidalClient.php index 2137775..ba5ab67 100644 --- a/src/Service/TidalClient.php +++ b/src/Service/TidalClient.php @@ -56,7 +56,7 @@ class TidalClient implements ExternalAlbumClientInterface return ['albums' => ['items' => []]]; } - $document = $this->fetchSearchAlbumsDocument($query, max(1, $page)); + $document = $this->fetchSearchAlbumsDocument($query, max(1, $page), max(1, $limit)); if ($document === []) { return ['albums' => ['items' => []]]; } @@ -301,10 +301,11 @@ class TidalClient implements ExternalAlbumClientInterface ]; } - private function fetchSearchAlbumsDocument(string $query, int $page): array + private function fetchSearchAlbumsDocument(string $query, int $page, int $limit): array { $safePage = max(1, $page); - $cacheKey = mb_strtolower($query) . '|' . $this->getCountryCode(); + $safeLimit = max(1, $limit); + $cacheKey = mb_strtolower($query) . '|' . $this->getCountryCode() . '|' . $safeLimit; $cursor = null; for ($currentPage = 1; $currentPage <= $safePage; $currentPage++) { @@ -316,6 +317,7 @@ class TidalClient implements ExternalAlbumClientInterface $queryParams = [ 'countryCode' => $this->getCountryCode(), 'include' => 'albums', + 'page[limit]' => min(100, $safeLimit), ]; if ($cursor !== null && $cursor !== '') { $queryParams['page[cursor]'] = $cursor; @@ -1011,4 +1013,3 @@ class TidalClient implements ExternalAlbumClientInterface return mb_convert_case(mb_strtolower($normalized), MB_CASE_TITLE, 'UTF-8'); } } - diff --git a/tests/Unit/Service/TidalClientTest.php b/tests/Unit/Service/TidalClientTest.php index b798db7..7f294ac 100644 --- a/tests/Unit/Service/TidalClientTest.php +++ b/tests/Unit/Service/TidalClientTest.php @@ -53,6 +53,7 @@ final class TidalClientTest extends TestCase self::assertStringContainsString('/searchResults/kanye%20west/relationships/albums', $uri); self::assertSame('US', $options['query']['countryCode'] ?? null); self::assertSame('albums', $options['query']['include'] ?? null); + self::assertSame(5, $options['query']['page[limit]'] ?? null); self::assertSame('Bearer token-abc', $options['headers']['Authorization'] ?? null); return new Response(200, ['Content-Type' => 'application/vnd.api+json'], json_encode([ @@ -231,4 +232,3 @@ final class TidalClientTest extends TestCase self::assertSame(1, $album['tracks']['items'][0]['track_number'] ?? null); } } -