316 lines
12 KiB
PHP
316 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Controller\Api\V1;
|
|
|
|
use App\Entity\Album;
|
|
use App\Entity\Review;
|
|
use App\Entity\User;
|
|
use App\Repository\AlbumRepository;
|
|
use App\Repository\ReviewRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use FOS\RestBundle\Controller\AbstractFOSRestController;
|
|
use OpenApi\Attributes as OA;
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
|
|
|
#[Route('/api/v1/reviews')]
|
|
class ReviewApiController extends AbstractFOSRestController
|
|
{
|
|
public function __construct(
|
|
private readonly ReviewRepository $reviewRepository,
|
|
private readonly AlbumRepository $albumRepository,
|
|
private readonly EntityManagerInterface $entityManager,
|
|
) {
|
|
}
|
|
|
|
#[Route('', name: 'api_v1_review_index', methods: ['GET'])]
|
|
#[OA\Get(
|
|
path: '/api/v1/reviews',
|
|
operationId: 'api_v1_review_index',
|
|
summary: 'List latest reviews',
|
|
tags: ['Reviews'],
|
|
parameters: [
|
|
new OA\Parameter(name: 'limit', in: 'query', required: false, schema: new OA\Schema(type: 'integer', default: 50)),
|
|
],
|
|
responses: [
|
|
new OA\Response(response: 200, description: 'Review list', content: new OA\JsonContent(type: 'object')),
|
|
]
|
|
)]
|
|
public function index(Request $request): Response
|
|
{
|
|
$limit = max(1, min(100, (int) $request->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<string,mixed>
|
|
*/
|
|
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<string,mixed>|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;
|
|
}
|
|
}
|