diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b5f24234..61780777 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -84,12 +84,6 @@ parameters: count: 2 path: src/Command/ListMissingDownloadsCommand.php - - - message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(mixed\)\: mixed\)\|null, Closure\(Symfony\\Component\\Validator\\ConstraintViolationInterface\)\: \(string\|Stringable\) given\.$#' - identifier: argument.type - count: 1 - path: src/Controller/Api/SitepackageController.php - - message: '#^Parameter \#1 \$packages of method App\\Service\\ComposerPackagesService\:\:cleanPackagesForVersions\(\) expects array\, array\ given\.$#' identifier: argument.type diff --git a/src/Controller/Api/AbstractController.php b/src/Controller/Api/AbstractController.php index 880183e4..64973e86 100644 --- a/src/Controller/Api/AbstractController.php +++ b/src/Controller/Api/AbstractController.php @@ -29,10 +29,16 @@ use App\Repository\ReleaseRepository; use App\Repository\RequirementRepository; use App\Service\CacheService; +use App\Service\SitepackageGenerator; use App\Utility\VersionUtility; use Doctrine\Inflector\InflectorFactory; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\Persistence\ManagerRegistry; +use Symfony\Component\Form\FormError; +use Symfony\Component\Form\FormErrorIterator; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\JsonResponse; +use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Serializer\SerializerInterface; @@ -51,6 +57,7 @@ public function __construct( private RequirementRepository $requirements, private ReleaseRepository $releases, private ValidatorInterface $validator, + private SitepackageGenerator $sitepackageGenerator, ) {} protected function getCache(): TagAwareCacheInterface @@ -88,6 +95,11 @@ protected function getReleases(): ReleaseRepository return $this->releases; } + protected function getSitepackageGenerator(): SitepackageGenerator + { + return $this->sitepackageGenerator; + } + protected function findMajorVersion(string $version): MajorVersion { $this->checkMajorVersionFormat($version); @@ -214,4 +226,40 @@ protected function flat(array $array, string $prefix = ''): array return $result; } + + /** + * @template T + * + * @param FormInterface $form + */ + protected function sendErroneousResponse(FormInterface $form): Response + { + return new JsonResponse([ + 'errors' => $this->getErrors($form), + ], Response::HTTP_BAD_REQUEST); + } + + /** + * @template T + * + * @param FormInterface $form + * + * @return array + */ + private function getErrors(FormInterface $form): array + { + $errors = []; + /** @var FormErrorIterator $formErrors */ + $formErrors = $form->getErrors(); + foreach ($formErrors as $error) { + $errors[] = $error->getMessage(); + } + foreach ($form->all() as $childForm) { + if (($childForm instanceof FormInterface) && count($childErrors = $this->getErrors($childForm)) > 0) { + $errors[$childForm->getName()] = $childErrors; + } + } + + return $errors; + } } diff --git a/src/Controller/Api/SitepackageController.php b/src/Controller/Api/SitepackageController.php index a18850bf..101db478 100644 --- a/src/Controller/Api/SitepackageController.php +++ b/src/Controller/Api/SitepackageController.php @@ -24,64 +24,54 @@ namespace App\Controller\Api; use App\Entity\Sitepackage; -use App\Service\SitepackageGenerator; +use App\Form\SitepackageType; use App\Utility\StringUtility; use Nelmio\ApiDocBundle\Annotation\Model; use OpenApi\Attributes as OA; -use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\BinaryFileResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\Routing\Attribute\Route; -use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\ConstraintViolationInterface; -use Symfony\Component\Validator\Validation; #[Route(path: '/api/v1/sitepackage', defaults: ['_format' => 'json'])] class SitepackageController extends AbstractController { - public function __construct( - protected SerializerInterface $serializer, - protected SitepackageGenerator $sitepackageGenerator - ) {} - #[Route(path: '/', methods: ['POST'])] - #[OA\RequestBody(required: true, content: new OA\JsonContent(ref: new Model(type: Sitepackage::class)))] + #[OA\RequestBody( + required: true, + content: new OA\JsonContent(ref: new Model(type: SitepackageType::class, options: ['csrf_protection' => false])), + )] #[OA\Response(response: 200, description: 'Successfully generated.', content: new OA\MediaType(mediaType: 'application/zip'))] #[OA\Response(response: 400, description: 'Request malformed.')] #[OA\Tag(name: 'sitepackage')] public function createSitepackage(Request $request): Response { - $content = $request->getContent(); - /** @var Sitepackage $sitepackage */ - $sitepackage = $this->serializer->deserialize($content, Sitepackage::class, 'json'); - $this->validateObject($sitepackage); + $content = json_decode($request->getContent(), true, 512, JSON_THROW_ON_ERROR); + if (!is_array($content)) { + $content = []; + } - $sitepackage->setVendorName(StringUtility::stringToUpperCamelCase($sitepackage->getAuthor()->getCompany())); - $sitepackage->setVendorNameAlternative(StringUtility::camelCaseToLowerCaseDashed($sitepackage->getVendorName())); - $sitepackage->setPackageName(StringUtility::stringToUpperCamelCase($sitepackage->getTitle())); - $sitepackage->setPackageNameAlternative(StringUtility::camelCaseToLowerCaseDashed($sitepackage->getPackageName())); - $sitepackage->setExtensionKey(StringUtility::camelCaseToLowerCaseUnderscored($sitepackage->getPackageName())); + $sitepackage = new Sitepackage(); + $form = $this->createForm(SitepackageType::class, $sitepackage, ['csrf_protection' => false]); + $form->submit($content, true); - $this->sitepackageGenerator->create($sitepackage); - $filename = $this->sitepackageGenerator->getFilename(); - BinaryFileResponse::trustXSendfileTypeHeader(); + if ($form->isValid()) { + $sitepackage->setVendorName(StringUtility::stringToUpperCamelCase($sitepackage->getAuthor()->getCompany())); + $sitepackage->setVendorNameAlternative(StringUtility::camelCaseToLowerCaseDashed($sitepackage->getVendorName())); + $sitepackage->setPackageName(StringUtility::stringToUpperCamelCase($sitepackage->getTitle())); + $sitepackage->setPackageNameAlternative(StringUtility::camelCaseToLowerCaseDashed($sitepackage->getPackageName())); + $sitepackage->setExtensionKey(StringUtility::camelCaseToLowerCaseUnderscored($sitepackage->getPackageName())); - return $this - ->file($this->sitepackageGenerator->getZipPath(), StringUtility::toASCII($filename)) - ->deleteFileAfterSend(true); - } + $sitepackageGenerator = $this->getSitepackageGenerator(); + $sitepackageGenerator->create($sitepackage); + $filename = $sitepackageGenerator->getFilename(); + BinaryFileResponse::trustXSendfileTypeHeader(); - protected function validateObject(mixed $object): void - { - $validator = Validation::createValidatorBuilder() - ->enableAttributeMapping() - ->getValidator(); - $errors = $validator->validate($object); - if (\count($errors) > 0) { - $errorsString = implode("\n", array_map(static fn(ConstraintViolationInterface $x) => $x->getMessage(), (array)$errors)); - throw new BadRequestHttpException($errorsString); + return $this + ->file($sitepackageGenerator->getZipPath(), StringUtility::toASCII($filename)) + ->deleteFileAfterSend(true); } + + return $this->sendErroneousResponse($form); } } diff --git a/src/Entity/Sitepackage.php b/src/Entity/Sitepackage.php index 613718a9..641b093f 100644 --- a/src/Entity/Sitepackage.php +++ b/src/Entity/Sitepackage.php @@ -24,7 +24,6 @@ namespace App\Entity; use App\Entity\Sitepackage\Author; -use OpenApi\Attributes as OA; use Symfony\Component\Validator\Constraints as Assert; /** @@ -34,12 +33,10 @@ class Sitepackage implements \JsonSerializable { #[Assert\NotBlank] #[Assert\Choice(['bootstrap_package', 'fluid_styled_content'])] - #[OA\Property(type: 'string', example: 'bootstrap_package')] private string $basePackage = 'bootstrap_package'; #[Assert\NotBlank] #[Assert\Choice([10.4, 11.5, 12.4, 13.4])] - #[OA\Property(type: 'float', example: 13.4)] private float $typo3Version = 13.4; private string $vendorName; @@ -49,11 +46,9 @@ class Sitepackage implements \JsonSerializable #[Assert\NotBlank(message: 'Please enter a title for your site package')] #[Assert\Length(min: 3)] #[Assert\Regex(pattern: '/^[A-Za-z0-9\x7f-\xff .:&-]+$/', message: 'Only letters, numbers and spaces are allowed')] - #[OA\Property(type: 'string', example: 'My Sitepackage')] private string $title; #[Assert\Regex(pattern: '/^[A-Za-z0-9\x7f-\xff .,:!?&-]+$/', message: 'Only letters, numbers and spaces are allowed')] - #[OA\Property(type: 'string', example: 'Project Configuration for Client')] private string $description; private string $packageName; @@ -61,7 +56,6 @@ class Sitepackage implements \JsonSerializable private string $extensionKey; #[Assert\Url] - #[OA\Property(type: 'string', example: 'https://github.com/FriendsOfTYPO3/introduction')] private string $repositoryUrl = ''; #[Assert\Valid] diff --git a/src/Entity/Sitepackage/Author.php b/src/Entity/Sitepackage/Author.php index 1760cba5..b720e86f 100644 --- a/src/Entity/Sitepackage/Author.php +++ b/src/Entity/Sitepackage/Author.php @@ -23,7 +23,6 @@ namespace App\Entity\Sitepackage; -use OpenApi\Attributes as OA; use Symfony\Component\Validator\Constraints as Assert; /** @@ -33,23 +32,19 @@ class Author implements \JsonSerializable { #[Assert\NotBlank(message: "Please enter the authors' name.")] #[Assert\Length(min: 3)] - #[OA\Property(type: 'string', example: 'J. Doe')] private string $name; #[Assert\NotBlank(message: "Please enter the authors' email address.")] #[Assert\Email(message: "The email '{{ value }}' is not a valid email.")] - #[OA\Property(type: 'string', example: 'info@typo3.com')] private string $email; #[Assert\NotBlank(message: "Please enter the authors' company.")] #[Assert\Length(min: 3)] #[Assert\Regex(pattern: '/^[A-Za-z0-9\x7f-\xff .:&-]+$/', message: 'Only letters, numbers and spaces are allowed')] - #[OA\Property(type: 'string', example: 'TYPO3')] private string $company; #[Assert\NotBlank(message: "Please enter the authors' homepage URL.")] #[Assert\Url] - #[OA\Property(type: 'string', example: 'https://typo3.com')] private string $homepage; public function getName(): string diff --git a/src/Form/AuthorType.php b/src/Form/AuthorType.php index 69c262ad..675b48ce 100644 --- a/src/Form/AuthorType.php +++ b/src/Form/AuthorType.php @@ -40,24 +40,36 @@ public function buildForm(FormBuilderInterface $builder, array $options) 'autocomplete' => 'off', 'placeholder' => 'John Doe', ], + 'documentation' => [ + 'example' => 'J. Doe', + ], ]) ->add('email', EmailType::class, [ 'attr' => [ 'autocomplete' => 'off', 'placeholder' => 'john.doe@example.com', ], + 'documentation' => [ + 'example' => 'info@typo3.com', + ], ]) ->add('company', TextType::class, [ 'attr' => [ 'autocomplete' => 'off', 'placeholder' => 'Company Inc.', ], + 'documentation' => [ + 'example' => 'TYPO3', + ], ]) ->add('homepage', TextType::class, [ 'attr' => [ 'autocomplete' => 'off', 'placeholder' => 'https://www.example.com', ], + 'documentation' => [ + 'example' => 'https://typo3.com', + ], ]); } diff --git a/src/Form/SitepackageType.php b/src/Form/SitepackageType.php index d497d388..30d5703e 100644 --- a/src/Form/SitepackageType.php +++ b/src/Form/SitepackageType.php @@ -40,7 +40,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void { $builder ->setAction($options['action']) - ->add('basePackage', ChoiceType::class, [ + ->add('base_package', ChoiceType::class, [ 'label' => 'Base Package', 'choices' => [ 'Bootstrap Package' => 'bootstrap_package', @@ -48,7 +48,7 @@ public function buildForm(FormBuilderInterface $builder, array $options): void ], 'expanded' => true, ]) - ->add('typo3Version', ChoiceType::class, [ + ->add('typo3_version', ChoiceType::class, [ 'label' => 'TYPO3 Version', 'choices' => [ '13.4' => 13.4, @@ -63,6 +63,9 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'autocomplete' => 'off', 'placeholder' => 'My Site Package', ], + 'documentation' => [ + 'example' => 'My Sitepackage', + ], ]) ->add('description', TextareaType::class, [ 'required' => false, @@ -71,8 +74,11 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'autocomplete' => 'off', 'placeholder' => 'Optional description for the use of this Site Package', ], + 'documentation' => [ + 'example' => 'Project Configuration for Client', + ], ]) - ->add('repositoryUrl', TextType::class, [ + ->add('repository_url', TextType::class, [ 'label' => 'Repository URL', 'required' => false, 'empty_data' => '', @@ -80,8 +86,13 @@ public function buildForm(FormBuilderInterface $builder, array $options): void 'autocomplete' => 'off', 'placeholder' => 'https://github.com/username/my_sitepackage', ], + 'documentation' => [ + 'example' => 'https://github.com/FriendsOfTYPO3/introduction', + ], ]) - ->add('author', AuthorType::class); + ->add('author', AuthorType::class, [ + 'csrf_protection' => false, + ]); } public function setDefaultOptions(OptionsResolver $resolver): void diff --git a/src/Service/SitepackageGenerator.php b/src/Service/SitepackageGenerator.php index 5da216c0..e28afdaa 100644 --- a/src/Service/SitepackageGenerator.php +++ b/src/Service/SitepackageGenerator.php @@ -53,8 +53,7 @@ public function create(Sitepackage $package): void $fileList = FileUtility::listDirectory($sourceDir); $zipFile = new \ZipArchive(); - $opened = $zipFile->open($this->zipPath, \ZipArchive::CREATE); - if ($opened === true) { + if ($zipFile->open($this->zipPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) === true) { foreach ($fileList as $file) { if ($file !== $this->zipPath && file_exists($file)) { $baseFileName = $this->createRelativeFilePath($file, $sourceDir); diff --git a/tests/Functional/Controller/Api/SitepackageControllerTest.php b/tests/Functional/Controller/Api/SitepackageControllerTest.php new file mode 100644 index 00000000..d4373423 --- /dev/null +++ b/tests/Functional/Controller/Api/SitepackageControllerTest.php @@ -0,0 +1,111 @@ +client->request( + 'POST', + '/api/v1/sitepackage/', + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/zip', + ], + (string)json_encode([ + 'base_package' => 'bootstrap_package', + 'typo3_version' => 13.4, + 'title' => 'My Sitepackage', + 'description' => 'Project Configuration for Client', + 'repository_url' => 'https://github.com/FriendsOfTYPO3/introduction', + 'author' => [ + 'name' => 'J. Doe', + 'email' => 'info@typo3.com', + 'company' => 'TYPO3', + 'homepage' => 'https://typo3.com', + ], + ]) + ); + + $response = $this->client->getResponse(); + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + /** + * @test + */ + public function validationMissingCheck(): void + { + $this->client->request( + 'POST', + '/api/v1/sitepackage/', + [], + [], + [ + 'CONTENT_TYPE' => 'application/json', + 'HTTP_ACCEPT' => 'application/zip', + ], + (string)json_encode([ + 'base_package' => 'bootstrap_package', + 'typo3_version' => 999, + ]) + ); + + $response = $this->client->getResponse(); + self::assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $responseContent = json_decode((string)$response->getContent(), true, 512, JSON_THROW_ON_ERROR); + self::assertSame($responseContent, [ + 'errors' => [ + 'typo3_version' => [ + 'The selected choice is invalid.', + ], + 'title' => [ + 'Please enter a title for your site package', + ], + 'author' => [ + 'name' => [ + 'Please enter the authors\' name.', + ], + 'email' => [ + 'Please enter the authors\' email address.', + ], + 'company' => [ + 'Please enter the authors\' company.', + ], + 'homepage' => [ + 'Please enter the authors\' homepage URL.', + ], + ], + ], + ]); + } +}