Files
tonehaus/src/Controller/Api/V1/ReviewApiController.php
T

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;
}
}