Skip to content

Commit

Permalink
Add Symfony attribute describers (#2112)
Browse files Browse the repository at this point in the history
* Add SymfonyDescriber

* Add SymfonyDescriber dependency injection

* Fix codestyle

* fixup! Fix codestyle

* Add php 8 checks

* Add symfony.xml loading

* Temp: increase max self deprecations

* Add Exception throw when invalid php version is used

* Fix codestyle

* Add describeRequestBody method

* Use elseif

* Only check for php version once

* Add SymfonyDescriberTest for MapRequestPayload

* Fix annotation

* Skip test if attributes don't exist

* Skip test based on php version

* Move $mapRequestPayload type to annotation

* Fix annotation style

* Fix SymfonyDescriberTest for older symfony versions

* Remove version check

* Remove usage of in_array to check for attribute

* Change elseif to separate if statement

* Fix testMapRequestPayloadParamRegistersRequestBody for split up if statement

* Add testMapQueryParameter

* Fix codestyle

* Remove newline

* Expand docs for symfony controller mapping

* Add backticks

* Clarify docs

* Upgrade major_version to 6

* Upgrade versionadded_directive_min_version to 6.0

* Revert max allowed self deprecations

* Revert phpunit.xml.dist changes

* Revert "Revert max allowed self deprecations"

This reverts commit c75b539.

* Remove not working generator bypass and replace with iterable

* Update testMapQueryParameter to work with 'controller' classes

* Update testMapRequestPayload to work with 'controller' classes

* Remove check for MapQueryString existence

* Fix codestyle

* Swap comparison order

* initial MapQueryString setup

* Move annotation describe methods to their own SymfonyAnnotationDescriber classes

* Cleanup

* Add annotation describer services

* Move to own test files

* Cleanup

* Call setModelRegistry on annotation describers

* Use accessible values for tests

* Add SymfonyMapQueryStringDescriberTest

* Only check availability of needed attribute

* Fix message

* Fix styleci

* Fix styleci

* Expand SymfonyMapQueryStringDescriber to copy property data to query parameter data

* Fix style

* Add missing newline

* Fix test php 7.2 compatability

* Remove annotation var name

* Fix missing values

* Fix missing values

* Add DTO testing class

* Expand SymfonyMapQueryStringDescriberTest

* Add SymfonyDescriberTest tests

* Remove unused import

* Remove trailing commas

* Copy ref

* Remove setting allowEmptyValue

* Remove empty value test

* Update documentation

* Merge documentation instead of overwriting

* Expand symfony controller mapping attribute documentation

* Fix RST

* Fix RST (missing blank line)

* Revert max self deprecations

* Use modelDescriber to describe model instead of registering all models

* Create weak context

* Get schema from property instead of manually setting every property

* Add newline at end of file

* Fix style

* Prevent overwriting non-default values

* Add functional test for MapQueryString

* Use modifyAnnotationValue helper method instead of overwriting

* Fix incorrect name is used for query

* Transform int to integer

* Remove allowEmptyValue

* Fix type comparison

* Fix enum not being used in test

* Add MapQueryParameter functional tests

* Update required statement

* Set requestBody required

* Add MapRequestPayload functional tests

* Fix style

* Cleanup array format check

* add required field to test

* fix baseline

* refactor logic to use symfony metadata instead of reflection

* style fix

* re-add manually iterating over describers

* re-add unit tests

* style fix

* remove named parameter

* move xml load logic to describers

* Revert "move xml load logic to describers"

This reverts commit 035db3f.

* major refactor

* remove tests

* style fix

* expand symfony map attribute tests

* fix multiple models generated when null

* generate proper nullable

* style fix

* remove property property from schema

* style fix

* handle reflection exception

* rename dir

* style fix

* Move MapRequestPayload describing to swagger processor

* fix baseline

* test overwriting to different model

* query testing for schema overwriting

* documentation update
  • Loading branch information
DjordyKoert committed Jan 15, 2024
1 parent e1405ef commit 9b642df
Show file tree
Hide file tree
Showing 16 changed files with 1,462 additions and 4 deletions.
6 changes: 3 additions & 3 deletions .doctor-rst.yaml
Expand Up @@ -53,10 +53,10 @@ rules:

# master
versionadded_directive_major_version:
major_version: 5
major_version: 6

versionadded_directive_min_version:
min_version: '5.0'
min_version: '6.0'

deprecated_directive_major_version:
major_version: 5
Expand All @@ -71,4 +71,4 @@ whitelist:
lines:
- '.. code-block:: twig'
- '// bin/console'
- '.. code-block:: php'
- '.. code-block:: php'
51 changes: 51 additions & 0 deletions DependencyInjection/NelmioApiDocExtension.php
Expand Up @@ -21,6 +21,13 @@
use Nelmio\ApiDocBundle\ModelDescriber\BazingaHateoasModelDescriber;
use Nelmio\ApiDocBundle\ModelDescriber\JMSModelDescriber;
use Nelmio\ApiDocBundle\ModelDescriber\ModelDescriberInterface;
use Nelmio\ApiDocBundle\Processors\MapQueryStringProcessor;
use Nelmio\ApiDocBundle\Processors\MapRequestPayloadProcessor;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\RouteArgumentDescriberInterface;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryParameterDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryStringDescriber;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber;
use Nelmio\ApiDocBundle\Routing\FilteredRouteCollectionBuilder;
use OpenApi\Generator;
use Symfony\Component\Config\FileLocator;
Expand All @@ -32,6 +39,9 @@
use Symfony\Component\DependencyInjection\Loader\XmlFileLoader;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
use Symfony\Component\Routing\RouteCollection;

Expand Down Expand Up @@ -170,6 +180,47 @@ public function load(array $configs, ContainerBuilder $container): void
->setArgument(1, $config['media_types']);
}

if (PHP_VERSION_ID > 80100) {
// Add autoconfiguration for route argument describer
$container->registerForAutoconfiguration(RouteArgumentDescriberInterface::class)
->addTag('nelmio_api_doc.route_argument_describer');

$container->register('nelmio_api_doc.route_describers.route_argument', RouteArgumentDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_describer', ['priority' => -225])
->setArguments([
new Reference('argument_metadata_factory'),
new TaggedIteratorArgument('nelmio_api_doc.route_argument_describer'),
])
;

if (class_exists(MapQueryString::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_query_string', SymfonyMapQueryStringDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);

$container->register('nelmio_api_doc.swagger.processor.map_query_string', MapQueryStringProcessor::class)
->setPublic(false)
->addTag('nelmio_api_doc.swagger.processor', ['priority' => 0]);
}

if (class_exists(MapRequestPayload::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_request_payload', SymfonyMapRequestPayloadDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);

$container->register('nelmio_api_doc.swagger.processor.map_request_payload', MapRequestPayloadProcessor::class)
->setPublic(false)
->addTag('nelmio_api_doc.swagger.processor', ['priority' => 0]);
}

if (class_exists(MapQueryParameter::class)) {
$container->register('nelmio_api_doc.route_argument_describer.map_query_parameter', SymfonyMapQueryParameterDescriber::class)
->setPublic(false)
->addTag('nelmio_api_doc.route_argument_describer', ['priority' => 0]);
}
}

$bundles = $container->getParameter('kernel.bundles');
if (!isset($bundles['TwigBundle']) || !class_exists('Symfony\Component\Asset\Packages')) {
$container->removeDefinition('nelmio_api_doc.controller.swagger_ui');
Expand Down
12 changes: 12 additions & 0 deletions OpenApiPhp/Util.php
Expand Up @@ -504,4 +504,16 @@ function ($value) {
$class::$_nested
));
}

/**
* Helper method to modify an annotation value only if its value has not yet been set.
*/
public static function modifyAnnotationValue(OA\AbstractAnnotation $parameter, string $property, $value): void
{
if (!Generator::isDefault($parameter->{$property})) {
return;
}

$parameter->{$property} = $value;
}
}
80 changes: 80 additions & 0 deletions Processors/MapQueryStringProcessor.php
@@ -0,0 +1,80 @@
<?php

declare(strict_types=1);

namespace Nelmio\ApiDocBundle\Processors;

use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapQueryStringDescriber;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Generator;
use OpenApi\Processors\ProcessorInterface;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

/**
* A processor that adds query parameters to operations that have a MapQueryString attribute.
* A processor is used to ensure that a Model has been created.
*
* @see SymfonyMapQueryStringDescriber
*/
final class MapQueryStringProcessor implements ProcessorInterface
{
public function __invoke(Analysis $analysis)
{
/** @var OA\Operation[] $operations */
$operations = $analysis->getAnnotationsOfType(OA\Operation::class);

foreach ($operations as $operation) {
if (!isset($operation->_context->{SymfonyMapQueryStringDescriber::CONTEXT_ARGUMENT_METADATA})) {
continue;
}

$argumentMetaData = $operation->_context->{SymfonyMapQueryStringDescriber::CONTEXT_ARGUMENT_METADATA};
if (!$argumentMetaData instanceof ArgumentMetadata) {
throw new \LogicException(sprintf('MapQueryString ArgumentMetaData not found for operation "%s"', $operation->operationId));
}

$modelRef = $operation->_context->{SymfonyMapQueryStringDescriber::CONTEXT_MODEL_REF};
if (!isset($modelRef)) {
throw new \LogicException(sprintf('MapQueryString Model reference not found for operation "%s"', $operation->operationId));
}

$nativeModelName = str_replace(OA\Components::SCHEMA_REF, '', $modelRef);

$schemaModel = Util::getSchema($analysis->openapi, $nativeModelName);

// There are no properties to map to query parameters
if (Generator::UNDEFINED === $schemaModel->properties) {
return;
}

$isModelOptional = $argumentMetaData->hasDefaultValue() || $argumentMetaData->isNullable();

foreach ($schemaModel->properties as $property) {
$operationParameter = Util::getOperationParameter($operation, $property->property, 'query');

// Remove incompatible properties
$propertyVars = get_object_vars($property);
unset($propertyVars['property']);

$schema = new OA\Schema($propertyVars);

Util::modifyAnnotationValue($operationParameter, 'schema', $schema);
Util::modifyAnnotationValue($operationParameter, 'name', $property->property);
Util::modifyAnnotationValue($operationParameter, 'description', $schema->description);
Util::modifyAnnotationValue($operationParameter, 'required', $schema->required);
Util::modifyAnnotationValue($operationParameter, 'deprecated', $schema->deprecated);
Util::modifyAnnotationValue($operationParameter, 'example', $schema->example);

if ($isModelOptional) {
Util::modifyAnnotationValue($operationParameter, 'required', false);
} elseif (is_array($schemaModel->required) && in_array($property->property, $schemaModel->required, true)) {
Util::modifyAnnotationValue($operationParameter, 'required', true);
} else {
Util::modifyAnnotationValue($operationParameter, 'required', false);
}
}
}
}
}
99 changes: 99 additions & 0 deletions Processors/MapRequestPayloadProcessor.php
@@ -0,0 +1,99 @@
<?php

declare(strict_types=1);

namespace Nelmio\ApiDocBundle\Processors;

use Nelmio\ApiDocBundle\OpenApiPhp\Util;
use Nelmio\ApiDocBundle\RouteDescriber\RouteArgumentDescriber\SymfonyMapRequestPayloadDescriber;
use OpenApi\Analysis;
use OpenApi\Annotations as OA;
use OpenApi\Processors\ProcessorInterface;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;
use Symfony\Component\HttpKernel\ControllerMetadata\ArgumentMetadata;

/**
* A processor that adds query parameters to operations that have a MapRequestPayload attribute.
* A processor is used to ensure that a Model has been created.
*
* @see SymfonyMapRequestPayloadDescriber
*/
final class MapRequestPayloadProcessor implements ProcessorInterface
{
public function __invoke(Analysis $analysis)
{
/** @var OA\Operation[] $operations */
$operations = $analysis->getAnnotationsOfType(OA\Operation::class);

foreach ($operations as $operation) {
if (!isset($operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA})) {
continue;
}

$argumentMetaData = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_ARGUMENT_METADATA};
if (!$argumentMetaData instanceof ArgumentMetadata) {
throw new \LogicException(sprintf('MapRequestPayload ArgumentMetaData not found for operation "%s"', $operation->operationId));
}

/** @var MapRequestPayload $attribute */
if (!$attribute = $argumentMetaData->getAttributes(MapRequestPayload::class, ArgumentMetadata::IS_INSTANCEOF)[0] ?? null) {
throw new \LogicException(sprintf('Operation "%s" does not contain attribute of "%s', $operation->operationId, MapRequestPayload::class));
}

$modelRef = $operation->_context->{SymfonyMapRequestPayloadDescriber::CONTEXT_MODEL_REF};
if (!isset($modelRef)) {
throw new \LogicException(sprintf('MapRequestPayload Model reference not found for operation "%s"', $operation->operationId));
}

/** @var OA\RequestBody $requestBody */
$requestBody = Util::getChild($operation, OA\RequestBody::class);
Util::modifyAnnotationValue($requestBody, 'required', !($argumentMetaData->hasDefaultValue() || $argumentMetaData->isNullable()));

$formats = $attribute->acceptFormat;
if (!is_array($formats)) {
$formats = [$attribute->acceptFormat ?? 'json'];
}

foreach ($formats as $format) {
$contentSchema = $this->getContentSchemaForType($requestBody, $format);
Util::modifyAnnotationValue($contentSchema, 'ref', $modelRef);

if ($argumentMetaData->isNullable()) {
$contentSchema->nullable = true;
}
}
}
}

private function getContentSchemaForType(OA\RequestBody $requestBody, string $type): OA\Schema
{
Util::modifyAnnotationValue($requestBody, 'content', []);
switch ($type) {
case 'json':
$contentType = 'application/json';

break;
case 'xml':
$contentType = 'application/xml';

break;
default:
throw new \InvalidArgumentException('Unsupported media type');
}

if (!isset($requestBody->content[$contentType])) {
$weakContext = Util::createWeakContext($requestBody->_context);
$requestBody->content[$contentType] = new OA\MediaType(
[
'mediaType' => $contentType,
'_context' => $weakContext,
]
);
}

return Util::getChild(
$requestBody->content[$contentType],
OA\Schema::class
);
}
}
9 changes: 8 additions & 1 deletion Resources/doc/index.rst
Expand Up @@ -7,7 +7,7 @@ OpenAPI (Swagger) format and provides a sandbox to interactively experiment with
What's supported?
-----------------

This bundle supports *Symfony* route requirements, PHP annotations, `Swagger-Php`_ annotations,
This bundle supports *Symfony* route requirements, *Symfony* request mapping (:doc:`symfony_attributes`), PHP annotations, `Swagger-Php`_ annotations,
`FOSRestBundle`_ annotations and applications using `Api-Platform`_.

.. _`Swagger-Php`: https://github.com/zircote/swagger-php
Expand Down Expand Up @@ -239,6 +239,12 @@ The normal PHPDoc block on the controller method is used for the summary and des
However, unlike in those examples, when using this bundle you don't need to specify paths and you can easily document models as well as some
other properties described below as they can be automatically be documented using the Symfony integration.

.. tip::

**NelmioApiDocBundle** understands **symfony's** controller attributes.
Using these attributes inside your controller allows this bundle to automatically create the necessary documentation.
More information can be found here: :doc:`symfony_attributes`.

Use Models
----------

Expand Down Expand Up @@ -576,6 +582,7 @@ If you need more complex features, take a look at:
commands
faq
security
symfony_attributes

.. _`SwaggerPHP examples`: https://github.com/zircote/swagger-php/tree/master/Examples
.. _`Symfony PropertyInfo component`: https://symfony.com/doc/current/components/property_info.html
Expand Down

0 comments on commit 9b642df

Please sign in to comment.