query->get('limit', 50))); $reviews = $this->reviewRepository->findLatest($limit); return $this->handleView($this->view([ 'data' => array_map(fn(Review $review) => $this->serializeReview($review), $reviews), ], Response::HTTP_OK)); } #[Route('/{id}', name: 'api_v1_review_show', requirements: ['id' => '\\d+'], methods: ['GET'])] #[OA\Get( path: '/api/v1/reviews/{id}', operationId: 'api_v1_review_show', summary: 'Get a review by ID', tags: ['Reviews'], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response(response: 200, description: 'Review details', content: new OA\JsonContent(type: 'object')), ] )] public function show(Review $review): Response { return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); } #[Route('', name: 'api_v1_review_create', methods: ['POST'])] #[IsGranted('ROLE_USER')] #[OA\Post( path: '/api/v1/reviews', operationId: 'api_v1_review_create', summary: 'Create a review', tags: ['Reviews'], security: [['bearerAuth' => []]], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( required: ['album_id', 'title', 'content', 'rating'], properties: [ new OA\Property(property: 'album_id', type: 'string'), new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'content', type: 'string'), new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10), ] ) ), responses: [ new OA\Response(response: 201, description: 'Review created', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 404, description: 'Album not found', content: new OA\JsonContent(type: 'object')), ] )] public function create(Request $request): Response { $payload = $this->readJsonPayload($request); if ($payload === null) { return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); } $albumId = trim((string) ($payload['album_id'] ?? '')); $album = $this->resolveAlbum($albumId); if (!$album instanceof Album) { return $this->handleView($this->view(['error' => 'album_not_found'], Response::HTTP_NOT_FOUND)); } $title = trim((string) ($payload['title'] ?? '')); $content = trim((string) ($payload['content'] ?? '')); $rating = (int) ($payload['rating'] ?? 0); if ($title === '' || $content === '' || $rating < 1 || $rating > 10) { return $this->handleView($this->view(['error' => 'title_content_rating_required'], Response::HTTP_BAD_REQUEST)); } if (mb_strlen($content) < 20) { return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); } $author = $this->getUser(); if (!$author instanceof User) { return $this->handleView($this->view(['error' => 'authentication_required'], Response::HTTP_UNAUTHORIZED)); } $review = new Review(); $review->setAlbum($album); $review->setAuthor($author); $review->setTitle($title); $review->setContent($content); $review->setRating($rating); $this->entityManager->persist($review); $this->entityManager->flush(); return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_CREATED)); } #[Route('/{id}', name: 'api_v1_review_update', requirements: ['id' => '\\d+'], methods: ['PUT', 'PATCH'])] #[IsGranted('ROLE_USER')] #[OA\Put( path: '/api/v1/reviews/{id}', operationId: 'api_v1_review_update_put', summary: 'Replace a review', tags: ['Reviews'], security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( properties: [ new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'content', type: 'string'), new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10), ] ) ), responses: [ new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')), ] )] #[OA\Patch( path: '/api/v1/reviews/{id}', operationId: 'api_v1_review_update_patch', summary: 'Partially update a review', tags: ['Reviews'], security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], requestBody: new OA\RequestBody( required: true, content: new OA\JsonContent( properties: [ new OA\Property(property: 'title', type: 'string'), new OA\Property(property: 'content', type: 'string'), new OA\Property(property: 'rating', type: 'integer', minimum: 1, maximum: 10), ] ) ), responses: [ new OA\Response(response: 200, description: 'Review updated', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 400, description: 'Validation failed', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')), ] )] public function update(Review $review, Request $request): Response { $this->denyAccessUnlessGranted('REVIEW_EDIT', $review); $payload = $this->readJsonPayload($request); if ($payload === null) { return $this->handleView($this->view(['error' => 'invalid_json'], Response::HTTP_BAD_REQUEST)); } if (array_key_exists('title', $payload)) { $title = trim((string) $payload['title']); if ($title === '') { return $this->handleView($this->view(['error' => 'title_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); } $review->setTitle($title); } if (array_key_exists('content', $payload)) { $content = trim((string) $payload['content']); if ($content === '') { return $this->handleView($this->view(['error' => 'content_cannot_be_empty'], Response::HTTP_BAD_REQUEST)); } if (mb_strlen($content) < 20) { return $this->handleView($this->view(['error' => 'content_too_short'], Response::HTTP_BAD_REQUEST)); } $review->setContent($content); } if (array_key_exists('rating', $payload)) { $rating = (int) $payload['rating']; if ($rating < 1 || $rating > 10) { return $this->handleView($this->view(['error' => 'rating_out_of_range'], Response::HTTP_BAD_REQUEST)); } $review->setRating($rating); } $this->entityManager->flush(); return $this->handleView($this->view(['data' => $this->serializeReview($review)], Response::HTTP_OK)); } #[Route('/{id}', name: 'api_v1_review_delete', requirements: ['id' => '\\d+'], methods: ['DELETE'])] #[IsGranted('ROLE_USER')] #[OA\Delete( path: '/api/v1/reviews/{id}', operationId: 'api_v1_review_delete', summary: 'Delete a review', tags: ['Reviews'], security: [['bearerAuth' => []]], parameters: [ new OA\Parameter(name: 'id', in: 'path', required: true, schema: new OA\Schema(type: 'integer')), ], responses: [ new OA\Response(response: 204, description: 'Review deleted'), new OA\Response(response: 401, description: 'JWT required', content: new OA\JsonContent(type: 'object')), new OA\Response(response: 403, description: 'Forbidden', content: new OA\JsonContent(type: 'object')), ] )] public function delete(Review $review): Response { $this->denyAccessUnlessGranted('REVIEW_DELETE', $review); $this->entityManager->remove($review); $this->entityManager->flush(); return $this->handleView($this->view(null, Response::HTTP_NO_CONTENT)); } /** * @return array */ private function serializeReview(Review $review): array { $album = $review->getAlbum(); return [ 'id' => $review->getId(), 'title' => $review->getTitle(), 'content' => $review->getContent(), 'rating' => $review->getRating(), 'created_at' => $review->getCreatedAt()?->format(DATE_ATOM), 'updated_at' => $review->getUpdatedAt()?->format(DATE_ATOM), 'author' => [ 'id' => $review->getAuthor()?->getId(), 'email' => $review->getAuthor()?->getEmail(), 'display_name' => $review->getAuthor()?->getDisplayName(), ], 'album' => [ 'id' => $album?->getExternalId(), 'name' => $album?->getName(), 'source' => $album?->getSource(), ], ]; } private function resolveAlbum(string $albumId): ?Album { if ($albumId === '') { return null; } return $this->albumRepository->findOneByExternalId($albumId); } /** * @return array|null */ private function readJsonPayload(Request $request): ?array { $content = trim($request->getContent()); if ($content === '') { return []; } try { $decoded = $request->toArray(); } catch (\JsonException) { return null; } return is_array($decoded) ? $decoded : null; } }