diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..272fee9 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,64 @@ +# from doctrine/instantiator: +# https://github.com/doctrine/instantiator/blob/97aa11bb71ad6259a8c5a1161b4de2d6cdcc5501/.github/workflows/continuous-integration.yml + +name: "CI" + +on: + pull_request: + branches: + - "*.x" + push: + branches: + - "*.x" + +env: + fail-fast: true + COMPOSER_ROOT_VERSION: "1.4" + +jobs: + phpunit: + name: "PHPUnit" + runs-on: "ubuntu-20.04" + + strategy: + matrix: + include: + - php-version: 7.1 + composer-flags: "--prefer-lowest" + - php-version: 7.2 + symfony-require: "3.4.*" + - php-version: 7.3 + symfony-require: "4.4.*" + - php-version: 7.3 + symfony-require: "^5.0" + - php-version: 8.0 + composer-flags: "--ignore-platform-reqs" + + steps: + - name: "Checkout" + uses: "actions/checkout@v2" + with: + fetch-depth: 2 + + - name: "Install PHP without coverage" + uses: "shivammathur/setup-php@v2" + with: + php-version: "${{ matrix.php-version }}" + coverage: "none" + + - name: "Cache dependencies installed with composer" + uses: "actions/cache@v2" + with: + path: "~/.composer/cache" + key: "php-${{ matrix.php-version }}-composer-locked-${{ hashFiles('composer.lock') }}" + restore-keys: "php-${{ matrix.php-version }}-composer-locked-" + + - name: "Install dependencies with composer" + env: + SYMFONY_REQUIRE: "${{ matrix.symfony-require }}" + run: | + composer global require --no-progress --no-scripts --no-plugins symfony/flex + composer update --no-interaction --no-progress ${{ matrix.composer-flags }} + + - name: "Run PHPUnit" + run: "./phpunit" diff --git a/.gitignore b/.gitignore index d4acd14..ae94d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,6 @@ /.php_cs /phpunit.xml /.phpunit +/.phpunit.result.cache /Tests/Functional/cache /Tests/Functional/logs diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index c3ddb59..0000000 --- a/.travis.yml +++ /dev/null @@ -1,36 +0,0 @@ -language: php - -php: - - 7.1 - - 7.2 - - 7.3 - - 7.4 - -sudo: false - -cache: - directories: - - .phpunit - - $HOME/.composer/cache - -matrix: - fast_finish: true - include: - - php: 7.1 - env: COMPOSER_FLAGS="--prefer-lowest" - - php: 7.3 - env: SYMFONY_VERSION=^4.0 - - php: 7.3 - env: SYMFONY_VERSION=^5.0 - - php: 7.4 - env: SYMFONY_VERSION=^4.0 - - php: 7.4 - env: SYMFONY_VERSION=^5.0 - -before_install: - - phpenv config-rm xdebug.ini || true - - if [ "$SYMFONY_VERSION" != "" ]; then composer require "symfony/symfony:${SYMFONY_VERSION}" --dev --no-update; fi; - -install: composer update --no-interaction $COMPOSER_FLAGS - -script: ./phpunit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75f90ec..18bf10e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ You MUST follow the [PSR-1](http://www.php-fig.org/psr/psr-1/) and should really read the recommendations. Can't wait? Use the [PHP-CS-Fixer tool](http://cs.sensiolabs.org/). -You MUST run the test suite. +You MUST run the test suite (run `composer update`, and then execute `vendor/bin/simple-phpunit`). You MUST write (or update) unit tests. diff --git a/Controller/SwaggerUiController.php b/Controller/SwaggerUiController.php index b927d00..2b3d962 100644 --- a/Controller/SwaggerUiController.php +++ b/Controller/SwaggerUiController.php @@ -61,5 +61,7 @@ final class SwaggerUiController Response::HTTP_OK, ['Content-Type' => 'text/html'] ); + + return $response->setCharset('UTF-8'); } } diff --git a/DependencyInjection/Compiler/ConfigurationPass.php b/DependencyInjection/Compiler/ConfigurationPass.php index df128be..8d10540 100644 --- a/DependencyInjection/Compiler/ConfigurationPass.php +++ b/DependencyInjection/Compiler/ConfigurationPass.php @@ -29,7 +29,11 @@ final class ConfigurationPass implements CompilerPassInterface $container->register('nelmio_api_doc.model_describers.form', FormModelDescriber::class) ->setPublic(false) ->addArgument(new Reference('form.factory')) + ->addArgument(new Reference('annotation_reader')) + ->addArgument($container->getParameter('nelmio_api_doc.media_types')) ->addTag('nelmio_api_doc.model_describer', ['priority' => 100]); } + + $container->getParameterBag()->remove('nelmio_api_doc.media_types'); } } diff --git a/DependencyInjection/NelmioApiDocExtension.php b/DependencyInjection/NelmioApiDocExtension.php index 5333409..107c5d2 100644 --- a/DependencyInjection/NelmioApiDocExtension.php +++ b/DependencyInjection/NelmioApiDocExtension.php @@ -62,6 +62,7 @@ final class NelmioApiDocExtension extends Extension implements PrependExtensionI ->setFactory([new Reference('router'), 'getRouteCollection']); $container->setParameter('nelmio_api_doc.areas', array_keys($config['areas'])); + $container->setParameter('nelmio_api_doc.media_types', $config['media_types']); foreach ($config['areas'] as $area => $areaConfig) { $nameAliases = $this->findNameAliases($config['models']['names'], $area); diff --git a/Exception/UndocumentedArrayItemsException.php b/Exception/UndocumentedArrayItemsException.php new file mode 100644 index 0000000..b44c05f --- /dev/null +++ b/Exception/UndocumentedArrayItemsException.php @@ -0,0 +1,42 @@ +class = $class; + $this->path = $path; + + $propertyName = ''; + if (null !== $class) { + $propertyName = $class.'::'; + } + $propertyName .= $path; + + parent::__construct(sprintf('Property "%s" is an array, but its items type isn\'t specified. You can specify that by using the type `string[]` for instance or `@SWG\Property(type="array", @SWG\Items(type="string"))`.', $propertyName)); + } + + public function getClass() + { + return $this->class; + } + + public function getPath() + { + return $this->path; + } +} diff --git a/Form/Extension/DocumentationExtension.php b/Form/Extension/DocumentationExtension.php index 0bebeef..2cc03a4 100644 --- a/Form/Extension/DocumentationExtension.php +++ b/Form/Extension/DocumentationExtension.php @@ -29,7 +29,7 @@ class DocumentationExtension extends AbstractTypeExtension public function configureOptions(OptionsResolver $resolver) { $resolver->setDefaults(['documentation' => []]) - ->setAllowedTypes('documentation', ['array']); + ->setAllowedTypes('documentation', ['array', 'bool']); } public function getExtendedType() diff --git a/ModelDescriber/FormModelDescriber.php b/ModelDescriber/FormModelDescriber.php index 57c6432..c4f423f 100644 --- a/ModelDescriber/FormModelDescriber.php +++ b/ModelDescriber/FormModelDescriber.php @@ -11,9 +11,11 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; +use Doctrine\Common\Annotations\Reader; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; use Nelmio\ApiDocBundle\Model\Model; +use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader; use Nelmio\ApiDocBundle\OpenApiPhp\Util; use OpenApi\Annotations as OA; use Symfony\Component\Form\AbstractType; @@ -33,10 +35,22 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry use ModelRegistryAwareTrait; private $formFactory; + private $doctrineReader; + private $mediaTypes; - public function __construct(FormFactoryInterface $formFactory = null) + public function __construct(FormFactoryInterface $formFactory = null, Reader $reader = null, array $mediaTypes = null) { $this->formFactory = $formFactory; + $this->doctrineReader = $reader; + if (null === $reader) { + @trigger_error(sprintf('Not passing a doctrine reader to the constructor of %s is deprecated since version 3.8 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED); + } + + if (null === $mediaTypes) { + $mediaTypes = ['json']; + @trigger_error(sprintf('Not passing media types to the constructor of %s is deprecated since version 4.1 and won\'t be allowed in version 5.', self::class), E_USER_DEPRECATED); + } + $this->mediaTypes = $mediaTypes; } public function describe(Model $model, OA\Schema $schema) @@ -52,6 +66,9 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry $class = $model->getType()->getClassName(); + $annotationsReader = new AnnotationsReader($this->doctrineReader, $this->modelRegistry, $this->mediaTypes); + $annotationsReader->updateDefinition(new \ReflectionClass($class), $schema); + $form = $this->formFactory->create($class, null, $model->getOptions() ?? []); $this->parseForm($schema, $form); } @@ -65,6 +82,11 @@ final class FormModelDescriber implements ModelDescriberInterface, ModelRegistry { foreach ($form as $name => $child) { $config = $child->getConfig(); + + // This field must not be documented + if ($config->hasOption('documentation') && false === $config->getOption('documentation')) { + continue; + } $property = Util::getProperty($schema, $name); if ($config->getRequired()) { diff --git a/ModelDescriber/ObjectModelDescriber.php b/ModelDescriber/ObjectModelDescriber.php index aaedea9..d1a0646 100644 --- a/ModelDescriber/ObjectModelDescriber.php +++ b/ModelDescriber/ObjectModelDescriber.php @@ -14,6 +14,7 @@ namespace Nelmio\ApiDocBundle\ModelDescriber; use Doctrine\Common\Annotations\Reader; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; +use Nelmio\ApiDocBundle\Exception\UndocumentedArrayItemsException; use Nelmio\ApiDocBundle\Model\Model; use Nelmio\ApiDocBundle\ModelDescriber\Annotations\AnnotationsReader; use Nelmio\ApiDocBundle\OpenApiPhp\Util; @@ -148,7 +149,15 @@ class ObjectModelDescriber implements ModelDescriberInterface, ModelRegistryAwar $propertyDescriber->setModelRegistry($this->modelRegistry); } if ($propertyDescriber->supports($types)) { - $propertyDescriber->describe($types, $property, $model->getGroups()); + try { + $propertyDescriber->describe($types, $property, $model->getGroups()); + } catch (UndocumentedArrayItemsException $e) { + if (null !== $e->getClass()) { + throw $e; // This exception is already complete + } + + throw new UndocumentedArrayItemsException($model->getType()->getClassName(), sprintf('%s%s', $propertyName, $e->getPath())); + } return; } diff --git a/PropertyDescriber/ArrayPropertyDescriber.php b/PropertyDescriber/ArrayPropertyDescriber.php index 6b59e12..cb02c36 100644 --- a/PropertyDescriber/ArrayPropertyDescriber.php +++ b/PropertyDescriber/ArrayPropertyDescriber.php @@ -13,6 +13,7 @@ namespace Nelmio\ApiDocBundle\PropertyDescriber; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareInterface; use Nelmio\ApiDocBundle\Describer\ModelRegistryAwareTrait; +use Nelmio\ApiDocBundle\Exception\UndocumentedArrayItemsException; use Nelmio\ApiDocBundle\OpenApiPhp\Util; use OpenApi\Annotations as OA; @@ -33,7 +34,7 @@ class ArrayPropertyDescriber implements PropertyDescriberInterface, ModelRegistr { $type = $types[0]->getCollectionValueType(); if (null === $type) { - throw new \LogicException(sprintf('Property "%s" is an array, but its items type isn\'t specified. You can specify that by using the type `string[]` for instance or `@OA\Property(type="array", @OA\Items(type="string"))`.', $property->property)); + throw new UndocumentedArrayItemsException(); } $property->type = 'array'; @@ -45,7 +46,15 @@ class ArrayPropertyDescriber implements PropertyDescriberInterface, ModelRegistr $propertyDescriber->setModelRegistry($this->modelRegistry); } if ($propertyDescriber->supports([$type])) { - $propertyDescriber->describe([$type], $property, $groups); + try { + $propertyDescriber->describe([$type], $property, $groups); + } catch (UndocumentedArrayItemsException $e) { + if (null !== $e->getClass()) { + throw $e; // This exception is already complete + } + + throw new UndocumentedArrayItemsException(null, sprintf('%s[]', $e->getPath())); + } break; } diff --git a/Tests/Functional/ArrayItemsErrorTest.php b/Tests/Functional/ArrayItemsErrorTest.php new file mode 100644 index 0000000..57a0ea0 --- /dev/null +++ b/Tests/Functional/ArrayItemsErrorTest.php @@ -0,0 +1,37 @@ + 'api.example.com']); + } + + public function testModelPictureDocumentation() + { + $this->expectException(UndocumentedArrayItemsException::class); + $this->expectExceptionMessage('Property "Nelmio\ApiDocBundle\Tests\Functional\Entity\ArrayItemsError\Bar::things[]" is an array, but its items type isn\'t specified.'); + + $this->getOpenApiDefinition(); + } + + protected static function createKernel(array $options = []) + { + return new TestKernel(TestKernel::ERROR_ARRAY_ITEMS); + } +} diff --git a/Tests/Functional/BazingaFunctionalTest.php b/Tests/Functional/BazingaFunctionalTest.php index 9ed1767..ff46e8d 100644 --- a/Tests/Functional/BazingaFunctionalTest.php +++ b/Tests/Functional/BazingaFunctionalTest.php @@ -125,6 +125,6 @@ class BazingaFunctionalTest extends WebTestCase protected static function createKernel(array $options = []) { - return new TestKernel(true, true); + return new TestKernel(TestKernel::USE_JMS | TestKernel::USE_BAZINGA); } } diff --git a/Tests/Functional/Controller/ArrayItemsErrorController.php b/Tests/Functional/Controller/ArrayItemsErrorController.php new file mode 100644 index 0000000..00c5751 --- /dev/null +++ b/Tests/Functional/Controller/ArrayItemsErrorController.php @@ -0,0 +1,35 @@ + + */ +class Bar +{ + public $things; + + public function addThing(array $thing) { } +} diff --git a/Tests/Functional/Entity/ArrayItemsError/Foo.php b/Tests/Functional/Entity/ArrayItemsError/Foo.php new file mode 100644 index 0000000..c9d7704 --- /dev/null +++ b/Tests/Functional/Entity/ArrayItemsError/Foo.php @@ -0,0 +1,28 @@ + + */ +class Foo +{ + /** + * @var string + */ + public $articles; + + /** + * @var Bar[] + */ + public $bars; +} diff --git a/Tests/Functional/Form/UserType.php b/Tests/Functional/Form/UserType.php index a05591c..a721b9b 100644 --- a/Tests/Functional/Form/UserType.php +++ b/Tests/Functional/Form/UserType.php @@ -12,6 +12,7 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Form; use Nelmio\ApiDocBundle\Tests\Functional\Entity\User; +use OpenApi\Annotations as OA; use Symfony\Component\Form\AbstractType; use Symfony\Component\Form\Extension\Core\Type\CollectionType; use Symfony\Component\Form\Extension\Core\Type\SubmitType; @@ -19,6 +20,11 @@ use Symfony\Component\Form\Extension\Core\Type\TextType; use Symfony\Component\Form\FormBuilderInterface; use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * @OA\Schema( + * description="this is the description of an user" + * ) + */ class UserType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options) @@ -42,6 +48,7 @@ class UserType extends AbstractType ->add('document', DocumentType::class, ['class' => 'Document']) ->add('documents', DocumentType::class, ['class' => 'Document', 'multiple' => true]) ->add('extended_builtin', ExtendedBuiltinType::class, ['required_option' => 'foo']) + ->add('hidden', DummyType::class, ['documentation' => false]) ->add('save', SubmitType::class); } diff --git a/Tests/Functional/FunctionalTest.php b/Tests/Functional/FunctionalTest.php index 36863a2..bedf0b5 100644 --- a/Tests/Functional/FunctionalTest.php +++ b/Tests/Functional/FunctionalTest.php @@ -224,6 +224,7 @@ class FunctionalTest extends WebTestCase { $this->assertEquals([ 'type' => 'object', + 'description' => 'this is the description of an user', 'properties' => [ 'strings' => [ 'items' => ['type' => 'string'], diff --git a/Tests/Functional/JMSFunctionalTest.php b/Tests/Functional/JMSFunctionalTest.php index 14bd468..aea35d2 100644 --- a/Tests/Functional/JMSFunctionalTest.php +++ b/Tests/Functional/JMSFunctionalTest.php @@ -324,6 +324,6 @@ class JMSFunctionalTest extends WebTestCase protected static function createKernel(array $options = []) { - return new TestKernel(true); + return new TestKernel(TestKernel::USE_JMS); } } diff --git a/Tests/Functional/SwaggerUiTest.php b/Tests/Functional/SwaggerUiTest.php index ea4f7f7..c19a0a6 100644 --- a/Tests/Functional/SwaggerUiTest.php +++ b/Tests/Functional/SwaggerUiTest.php @@ -33,6 +33,7 @@ class SwaggerUiTest extends WebTestCase $response = $this->client->getResponse(); $this->assertEquals(200, $response->getStatusCode()); + $this->assertEquals('UTF-8', $response->getCharset()); $this->assertEquals('text/html; charset=UTF-8', $response->headers->get('Content-Type')); $expected = json_decode($this->getOpenApiDefinition()->toJson(), true); diff --git a/Tests/Functional/TestKernel.php b/Tests/Functional/TestKernel.php index 828ba08..f2d6d6b 100644 --- a/Tests/Functional/TestKernel.php +++ b/Tests/Functional/TestKernel.php @@ -33,17 +33,19 @@ use Symfony\Component\Serializer\Annotation\SerializedName; class TestKernel extends Kernel { + const USE_JMS = 1; + const USE_BAZINGA = 2; + const ERROR_ARRAY_ITEMS = 4; + use MicroKernelTrait; - private $useJMS; - private $useBazinga; + private $flags; - public function __construct(bool $useJMS = false, bool $useBazinga = false) + public function __construct(int $flags = 0) { - parent::__construct('test'.(int) $useJMS.(int) $useBazinga, true); + parent::__construct('test'.$flags, true); - $this->useJMS = $useJMS; - $this->useBazinga = $useBazinga; + $this->flags = $flags; } /** @@ -61,10 +63,10 @@ class TestKernel extends Kernel new FOSRestBundle(), ]; - if ($this->useJMS) { + if ($this->flags & self::USE_JMS) { $bundles[] = new JMSSerializerBundle(); - if ($this->useBazinga) { + if ($this->flags & self::USE_BAZINGA) { $bundles[] = new BazingaHateoasBundle(); } } @@ -91,11 +93,11 @@ class TestKernel extends Kernel $routes->import(__DIR__.'/Controller/SerializedNameController.php', '/', 'annotation'); } - if ($this->useJMS) { + if ($this->flags & self::USE_JMS) { $routes->import(__DIR__.'/Controller/JMSController.php', '/', 'annotation'); } - if ($this->useBazinga) { + if ($this->flags & self::USE_BAZINGA) { $routes->import(__DIR__.'/Controller/BazingaController.php', '/', 'annotation'); try { @@ -104,6 +106,10 @@ class TestKernel extends Kernel } catch (\ReflectionException $e) { } } + + if ($this->flags & self::ERROR_ARRAY_ITEMS) { + $routes->import(__DIR__.'/Controller/ArrayItemsErrorController.php', '/', 'annotation'); + } } /** @@ -250,7 +256,7 @@ class TestKernel extends Kernel */ public function getCacheDir() { - return parent::getCacheDir().'/'.(int) $this->useJMS; + return parent::getCacheDir().'/'.$this->flags; } /** @@ -258,7 +264,7 @@ class TestKernel extends Kernel */ public function getLogDir() { - return parent::getLogDir().'/'.(int) $this->useJMS; + return parent::getLogDir().'/'.$this->flags; } public function serialize() diff --git a/composer.json b/composer.json index fa15afd..bd4a191 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,7 @@ } ], "require": { - "php": "^7.1", + "php": ">=7.1.3", "ext-json": "*", "symfony/framework-bundle": "^4.0|^5.0", "symfony/options-resolver": "^4.0|^5.0", @@ -43,7 +43,7 @@ "doctrine/common": "^2.4", "api-platform/core": "^2.4", - "friendsofsymfony/rest-bundle": "^2.0|^3.0@beta", + "friendsofsymfony/rest-bundle": "^2.0|^3.0@dev", "willdurand/hateoas-bundle": "^1.0|^2.0", "jms/serializer-bundle": "^2.3|^3.0", "jms/serializer": "^1.14|^3.0" diff --git a/phpunit b/phpunit index 79af86a..ffa70d2 100755 --- a/phpunit +++ b/phpunit @@ -5,5 +5,4 @@ if (!file_exists(__DIR__.'/vendor/symfony/phpunit-bridge/bin/simple-phpunit')) { exit(1); } putenv('SYMFONY_PHPUNIT_DIR='.__DIR__.'/.phpunit'); -putenv('SYMFONY_PHPUNIT_VERSION=7.5'); require __DIR__.'/vendor/symfony/phpunit-bridge/bin/simple-phpunit';