diff --git a/src/ApiPlatform/Resources/Attachment/Attachment.php b/src/ApiPlatform/Resources/Attachment/Attachment.php new file mode 100644 index 000000000..46e982f25 --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/Attachment.php @@ -0,0 +1,136 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\DefaultLanguage; +use PrestaShop\PrestaShop\Core\ConstraintValidator\Constraints\TypedRegex; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Command\AddAttachmentCommand; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Command\DeleteAttachmentCommand; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Command\EditAttachmentCommand; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\AttachmentConstraintException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\AttachmentNotFoundException; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Query\GetAttachmentForEditing; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSCreate; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSGet; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSUpdate; +use PrestaShopBundle\ApiPlatform\Metadata\LocalizedValue; +use Symfony\Component\HttpFoundation\File\File; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSGet( + uriTemplate: '/attachments/{attachmentId}', + requirements: ['attachmentId' => '\d+'], + CQRSQuery: GetAttachmentForEditing::class, + scopes: [ + 'attachment_read', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + ), + new CQRSCreate( + uriTemplate: '/attachments', + inputFormats: ['multipart' => ['multipart/form-data']], + // Form data value are all string so we disable type enforcement + denormalizationContext: [ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true], + validationContext: ['groups' => ['Default', 'Create']], + CQRSCommand: AddAttachmentCommand::class, + CQRSQuery: GetAttachmentForEditing::class, + scopes: [ + 'attachment_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSUpdate( + // We have to force POST request, because we cannot use PUT with files AND data + method: CQRSUpdate::METHOD_POST, + uriTemplate: '/attachments/{attachmentId}', + requirements: ['attachmentId' => '\d+'], + inputFormats: ['multipart' => ['multipart/form-data']], + status: Response::HTTP_OK, + validationContext: ['groups' => ['Default', 'Update']], + // Form data value are all string so we disable type enforcement + denormalizationContext: [ObjectNormalizer::DISABLE_TYPE_ENFORCEMENT => true], + CQRSCommand: EditAttachmentCommand::class, + CQRSQuery: GetAttachmentForEditing::class, + scopes: [ + 'attachment_write', + ], + CQRSQueryMapping: self::QUERY_MAPPING, + CQRSCommandMapping: self::COMMAND_MAPPING, + ), + new CQRSDelete( + uriTemplate: '/attachments/{attachmentId}', + requirements: ['attachmentId' => '\d+'], + CQRSCommand: DeleteAttachmentCommand::class, + scopes: [ + 'attachment_write', + ], + ), + ], + exceptionToStatus: [ + AttachmentConstraintException::class => Response::HTTP_UNPROCESSABLE_ENTITY, + AttachmentNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class Attachment +{ + #[ApiProperty(identifier: true)] + public int $attachmentId; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'names')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'names', allowNull: true)] + #[Assert\All(constraints: [ + new TypedRegex([ + 'type' => TypedRegex::TYPE_CATALOG_NAME, + ]), + ])] + public array $names; + + #[LocalizedValue] + #[DefaultLanguage(groups: ['Create'], fieldName: 'descriptions')] + #[DefaultLanguage(groups: ['Update'], fieldName: 'descriptions', allowNull: true)] + public array $descriptions; + + public ?File $attachment = null; + + public const QUERY_MAPPING = [ + '[name]' => '[names]', + '[description]' => '[descriptions]', + ]; + + public const COMMAND_MAPPING = [ + '[names]' => '[localizedNames]', + '[descriptions]' => '[localizedDescriptions]', + '[attachment].pathName' => '[pathName]', + '[attachment].size' => '[fileSize]', + '[attachment].mimeType' => '[mimeType]', + '[attachment].originalName' => '[originalName]', + '[attachment].clientOriginalName' => '[originalName]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attachment/AttachmentList.php b/src/ApiPlatform/Resources/Attachment/AttachmentList.php new file mode 100644 index 000000000..1f957dccc --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/AttachmentList.php @@ -0,0 +1,60 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Search\Filters\AttachmentFilters; +use PrestaShopBundle\ApiPlatform\Metadata\PaginatedList; + +#[ApiResource( + operations: [ + new PaginatedList( + uriTemplate: '/attachments', + scopes: [ + 'attachment_read', + ], + ApiResourceMapping: self::MAPPING, + gridDataFactory: 'prestashop.core.grid.data_factory.attachment_decorator', + filtersClass: AttachmentFilters::class, + filtersMapping: [ + '[attachmentId]' => '[id_attachment]', + ], + ), + ] +)] +class AttachmentList +{ + #[ApiProperty(identifier: true)] + public int $attachmentId; + + public string $name; + + public string $file; + + public string $file_size; + + public string $products; + + public const MAPPING = [ + '[id_attachment]' => '[attachmentId]', + ]; +} diff --git a/src/ApiPlatform/Resources/Attachment/BulkAttachments.php b/src/ApiPlatform/Resources/Attachment/BulkAttachments.php new file mode 100644 index 000000000..d0473962e --- /dev/null +++ b/src/ApiPlatform/Resources/Attachment/BulkAttachments.php @@ -0,0 +1,54 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +namespace PrestaShop\Module\APIResources\ApiPlatform\Resources\Attachment; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Command\BulkDeleteAttachmentsCommand; +use PrestaShop\PrestaShop\Core\Domain\Attachment\Exception\AttachmentNotFoundException; +use PrestaShopBundle\ApiPlatform\Metadata\CQRSDelete; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new CQRSDelete( + uriTemplate: '/attachments/bulk-delete', + output: false, + CQRSCommand: BulkDeleteAttachmentsCommand::class, + scopes: [ + 'attachment_write', + ], + ), + ], + exceptionToStatus: [ + AttachmentNotFoundException::class => Response::HTTP_NOT_FOUND, + ], +)] +class BulkAttachments +{ + /** + * @var int[] + */ + #[ApiProperty(openapiContext: ['type' => 'array', 'items' => ['type' => 'integer'], 'example' => [1, 3]])] + #[Assert\NotBlank] + public array $attachmentIds; +} diff --git a/tests/Integration/ApiPlatform/AttachmentEndpointTest.php b/tests/Integration/ApiPlatform/AttachmentEndpointTest.php new file mode 100644 index 000000000..f53188838 --- /dev/null +++ b/tests/Integration/ApiPlatform/AttachmentEndpointTest.php @@ -0,0 +1,221 @@ + + * @copyright Since 2007 PrestaShop SA and Contributors + * @license https://opensource.org/licenses/AFL-3.0 Academic Free License version 3.0 + */ + +declare(strict_types=1); + +namespace PsApiResourcesTest\Integration\ApiPlatform; + +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Tests\Resources\DatabaseDump; +use Tests\Resources\Resetter\LanguageResetter; + +class AttachmentEndpointTest extends ApiTestCase +{ + public static function setUpBeforeClass(): void + { + parent::setUpBeforeClass(); + LanguageResetter::resetLanguages(); + self::addLanguageByLocale('fr-FR'); + self::resetTables(); + self::createApiClient(['attachment_read', 'attachment_write']); + } + + public static function tearDownAfterClass(): void + { + parent::tearDownAfterClass(); + LanguageResetter::resetLanguages(); + self::resetTables(); + } + + protected static function resetTables(): void + { + DatabaseDump::restoreTables([ + 'attachment', + 'attachment_lang', + ]); + } + + public static function getProtectedEndpoints(): iterable + { + yield 'get endpoint' => [ + 'GET', + '/attachments/1', + ]; + + yield 'create endpoint' => [ + 'POST', '/attachments', + 'multipart/form-data', + ]; + + yield 'update endpoint' => [ + 'POST', '/attachments/1', + 'multipart/form-data', + ]; + + yield 'delete endpoint' => [ + 'DELETE', + '/attachments/1', + ]; + + yield 'list endpoint' => [ + 'GET', + '/attachments', + ]; + + yield 'bulk delete endpoint' => [ + 'DELETE', + '/attachments/bulk-delete', + ]; + } + + public function testAddAttachment(): int + { + $attachment = $this->createAttachment(); + + $this->assertArrayHasKey('attachmentId', $attachment); + $this->assertIsInt($attachment['attachmentId']); + $this->assertSame('Attachment EN', $attachment['names']['en-US']); + $this->assertSame('Attachment FR', $attachment['names']['fr-FR']); + + return $attachment['attachmentId']; + } + + /** + * @depends testAddAttachment + */ + public function testGetAttachment(int $attachmentId): int + { + $attachment = $this->getItem('/attachments/' . $attachmentId, ['attachment_read']); + $this->assertEquals($attachmentId, $attachment['attachmentId']); + $this->assertArrayHasKey('names', $attachment); + + return $attachmentId; + } + + /** + * @depends testGetAttachment + */ + public function testUpdateAttachment(int $attachmentId): int + { + $uploadedFile = $this->prepareUploadedFile(__DIR__ . '/../../Resources/assets/image/Brown_bear_cushion.jpg'); + + // Special type of request, requires multipart/form-data content-type and upload a file via the request + $updated = $this->requestApi(Request::METHOD_POST, '/attachments/' . $attachmentId, null, ['attachment_write'], Response::HTTP_OK, [ + 'headers' => [ + 'content-type' => 'multipart/form-data', + ], + 'extra' => [ + 'parameters' => [ + 'names' => [ + 'en-US' => 'Updated attachment EN', + 'fr-FR' => 'Updated attachment FR', + ], + 'descriptions' => [ + 'en-US' => 'Updated description EN', + 'fr-FR' => 'Updated description FR', + ], + ], + 'files' => [ + 'attachment' => $uploadedFile, + ], + ], + ]); + + $this->assertArrayHasKey('attachmentId', $updated); + $this->assertEquals($attachmentId, $updated['attachmentId']); + $this->assertSame('Updated attachment EN', $updated['names']['en-US']); + + return $attachmentId; + } + + /** + * @depends testUpdateAttachment + */ + public function testListAttachments(int $attachmentId): int + { + $list = $this->listItems('/attachments?orderBy=attachmentId&sortOrder=desc', ['attachment_read']); + $this->assertGreaterThanOrEqual(1, $list['totalItems']); + $this->assertEquals('attachmentId', $list['orderBy']); + + $first = $list['items'][0]; + $this->assertEquals($attachmentId, $first['attachmentId']); + + return $attachmentId; + } + + /** + * @depends testListAttachments + */ + public function testRemoveAttachment(int $attachmentId): void + { + $this->deleteItem('/attachments/' . $attachmentId, ['attachment_write']); + $this->getItem('/attachments/' . $attachmentId, ['attachment_read'], Response::HTTP_NOT_FOUND); + } + + public function testBulkDeleteAttachments(): void + { + $removeAttachmentIds = []; + for ($count = 0; $count < 3; ++$count) { + $attachment = $this->createAttachment(); + $removeAttachmentIds[] = $attachment['attachmentId']; + } + + $this->deleteBatch('/attachments/bulk-delete', [ + 'attachmentIds' => $removeAttachmentIds, + ], ['attachment_write'], Response::HTTP_NO_CONTENT); + + foreach ($removeAttachmentIds as $attachmentId) { + $this->getItem('/attachments/' . $attachmentId, ['attachment_read'], Response::HTTP_NOT_FOUND); + } + } + + protected function createAttachment(): array|string|null + { + $uploadedFile = $this->prepareUploadedFile(__DIR__ . '/../../Resources/assets/image/Brown_bear_cushion.jpg'); + + // Special type of request, requires multipart/form-data content-type and upload a file via the request + return $this->requestApi(Request::METHOD_POST, '/attachments', null, ['attachment_write'], Response::HTTP_CREATED, [ + 'headers' => [ + 'content-type' => 'multipart/form-data', + ], + 'extra' => [ + 'parameters' => [ + 'names' => [ + 'en-US' => 'Attachment EN', + 'fr-FR' => 'Attachment FR', + ], + 'descriptions' => [ + 'en-US' => 'Test description EN', + 'fr-FR' => 'Description FR', + ], + ], + 'files' => [ + 'attachment' => $uploadedFile, + ], + ], + ]); + } + + protected function deleteBatch(string $endPointUrl, ?array $data, array $scopes = [], ?int $expectedHttpCode = null, ?array $requestOptions = null): array|string|null + { + return $this->requestApi(Request::METHOD_DELETE, $endPointUrl, $data, $scopes, $expectedHttpCode, $requestOptions); + } +}