Skip to content

Feature/rector attributes #11

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: feature/resource-attribute-aliased
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"psr/log": "^1.0",
"ramsey/uuid": "^3.7 || ^4.0",
"ramsey/uuid-doctrine": "^1.4",
"rector/rector": "^0.11.7",
"soyuka/contexts": "^3.3.1",
"soyuka/stubs-mongodb": "^1.0",
"symfony/asset": "^3.4 || ^4.4 || ^5.1",
Expand Down
46 changes: 46 additions & 0 deletions src/Bridge/Rector/Resolver/OperationClassResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace ApiPlatform\Core\Bridge\Rector\Resolver;

use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;

class OperationClassResolver
{
private static array $operationsClass = [
'itemOperations' => [
'get' => Get::class,
'put' => Put::class,
'patch' => Patch::class,
'delete' => Delete::class,
'post' => Post::class,
],
'collectionOperations' => [
'get' => GetCollection::class,
'post' => Post::class,
],
];

public static function resolve(string $operationName, string $operationType, array $arguments): string
{
if (array_key_exists($operationName, self::$operationsClass[$operationType])) {
return self::$operationsClass[$operationType][$operationName];
}

if (isset($arguments['method'])) {
$method = strtolower($arguments['method']);

if (isset(self::$operationsClass[$operationType][$method])) {
return self::$operationsClass[$operationType][$method];
}
}

throw new \Exception(sprintf('Unable to resolve operation class for %s "%s"', $operationType, $operationName));
}
}
70 changes: 70 additions & 0 deletions src/Bridge/Rector/Rules/AbstractApiResourceToResourceAttribute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

declare(strict_types=1);

namespace ApiPlatform\Core\Bridge\Rector\Rules;

use ApiPlatform\Core\Bridge\Rector\Resolver\OperationClassResolver;
use PhpParser\Node\AttributeGroup;
use Rector\Core\Rector\AbstractRector;
use Rector\PhpAttribute\Printer\PhpAttributeGroupFactory;
use Symfony\Component\String\UnicodeString;

abstract class AbstractApiResourceToResourceAttribute extends AbstractRector
{
protected PhpAttributeGroupFactory $phpAttributeGroupFactory;

protected array $operationTypes = ['collectionOperations', 'itemOperations'];

protected function normalizeOperations(array $operations): array
{
foreach ($operations as $name => $arguments) {
/**
* Case of custom action, ex:
* itemOperations={
* "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiers"="isbn"}
* }
*/
if (is_array($arguments)) {
// add operation name
$arguments = ['operationName' => $name] + $arguments;
foreach ($arguments as $key => $argument) {
// camelize argument name
$camelizedKey = (string) (new UnicodeString($key))->camel();
if ($key === $camelizedKey) {
continue;
}
$arguments[$camelizedKey] = $argument;
unset($arguments[$key]);
}
}

/**
* Case of default action, ex:
* collectionOperations={"get", "post"},
* itemOperations={"get", "put", "delete"},
*/
if (is_string($arguments)) {
unset($operations[$name]);
$name = $arguments;
$arguments = [];
}

$operations[$name] = $arguments;
}

return $operations;
}

protected function createOperationAttributeGroup(string $type, string $name, array $arguments): AttributeGroup
{
$operationClass = OperationClassResolver::resolve($name, $type, $arguments);

// remove unnecessary argument "method" after resolving the operation class
if (isset($arguments['method'])) {
unset($arguments['method']);
}

return $this->phpAttributeGroupFactory->createFromClassWithItems($operationClass, $arguments);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<?php

namespace ApiPlatform\Core\Bridge\Rector\Rules;

use ApiPlatform\Core\Annotation\ApiResource;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode;
use Rector\BetterPhpDocParser\PhpDoc\DoctrineAnnotationTagValueNode;
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo;
use Rector\BetterPhpDocParser\PhpDocManipulator\PhpDocTagRemover;
use Rector\Core\Contract\Rector\ConfigurableRectorInterface;
use Rector\Core\ValueObject\PhpVersionFeature;
use Rector\Php80\ValueObject\AnnotationToAttribute;
use Rector\PhpAttribute\Printer\PhpAttributeGroupFactory;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
use Webmozart\Assert\Assert;

final class ApiResourceAnnotationToResourceAttributeRector extends AbstractApiResourceToResourceAttribute implements ConfigurableRectorInterface
{
/**
* @var string
*/
public const ANNOTATION_TO_ATTRIBUTE = 'api_resource_annotation_to_resource_attribute';
/**
* @var string
*/
public const REMOVE_TAG = 'remove_tag';
/**
* @var AnnotationToAttribute[]
*/
private $annotationsToAttributes = [];
/**
* @var bool
*/
private $removeTag;
/**
* @var PhpDocTagRemover
*/
private $phpDocTagRemover;

public function __construct(PhpAttributeGroupFactory $phpAttributeGroupFactory, PhpDocTagRemover $phpDocTagRemover)
{
$this->phpAttributeGroupFactory = $phpAttributeGroupFactory;
$this->phpDocTagRemover = $phpDocTagRemover;
}

public function getRuleDefinition() : RuleDefinition
{
return new RuleDefinition('Change annotation to attribute', [new ConfiguredCodeSample(<<<'CODE_SAMPLE'
use ApiPlatform\Core\Annotation\ApiResource;

/**
* @ApiResource(collectionOperations={}, itemOperations={
* "get",
* "get_by_isbn"={"method"="GET", "path"="/books/by_isbn/{isbn}.{_format}", "requirements"={"isbn"=".+"}, "identifiers"="isbn"}
* })
*/
class Book
CODE_SAMPLE
, <<<'CODE_SAMPLE'
use ApiPlatform\Metadata\Resource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Core\Annotation\ApiResource;

#[Resource]
#[Get]
#[Get(operationName: 'get_by_isbn', path: '/books/by_isbn/{isbn}.{_format}', requirements: ['isbn' => '.+'], identifiers: 'isbn')]
class Book
CODE_SAMPLE
, [
self::ANNOTATION_TO_ATTRIBUTE => [new AnnotationToAttribute(ApiResource::class, ApiResource::class)],
self::REMOVE_TAG => true,
])
]);
}

/**
* @return array<class-string<Node>>
*/
public function getNodeTypes() : array
{
return [Class_::class];
}

/**
* @param Class_ $node
*/
public function refactor(Node $node) : ?Node
{
if (!$this->isAtLeastPhpVersion(PhpVersionFeature::ATTRIBUTES)) {
return null;
}
$phpDocInfo = $this->phpDocInfoFactory->createFromNode($node);
if (!$phpDocInfo instanceof PhpDocInfo) {
return null;
}
$tags = $phpDocInfo->getAllTags();
$hasNewAttrGroups = $this->processApplyAttrGroups($tags, $phpDocInfo, $node);
if ($hasNewAttrGroups) {
return $node;
}
return null;
}

/**
* @param array<string, AnnotationToAttribute[]> $configuration
*/
public function configure(array $configuration) : void
{
$annotationsToAttributes = $configuration[self::ANNOTATION_TO_ATTRIBUTE] ?? [];
Assert::allIsInstanceOf($annotationsToAttributes, AnnotationToAttribute::class);
$this->annotationsToAttributes = $annotationsToAttributes;
$this->removeTag = $configuration[self::REMOVE_TAG] ?? true;
}

/**
* @param array<PhpDocTagNode> $tags
* @param Class_ $node
*/
private function processApplyAttrGroups(array $tags, PhpDocInfo $phpDocInfo, Node $node) : bool
{
$hasNewAttrGroups = \false;
foreach ($tags as $tag) {
foreach ($this->annotationsToAttributes as $annotationToAttribute) {
$annotationToAttributeTag = $annotationToAttribute->getTag();
if ($phpDocInfo->hasByName($annotationToAttributeTag)) {
if (true === $this->removeTag) {
// 1. remove php-doc tag
$this->phpDocTagRemover->removeByName($phpDocInfo, $annotationToAttributeTag);
}
// 2. add attributes
$node->attrGroups[] = $this->phpAttributeGroupFactory->createFromSimpleTag($annotationToAttribute);
$hasNewAttrGroups = \true;
continue 2;
}
if ($this->shouldSkip($tag->value, $phpDocInfo, $annotationToAttributeTag)) {
continue;
}

if (true === $this->removeTag) {
// 1. remove php-doc tag
$this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $tag->value);
}
// 2. add attributes
/** @var DoctrineAnnotationTagValueNode $tagValue */
$tagValue = clone $tag->value;
$this->resolveOperations($tagValue, $node);
$resourceAttributeGroup = $this->phpAttributeGroupFactory->create($tagValue, $annotationToAttribute);
array_unshift($node->attrGroups, $resourceAttributeGroup);
$hasNewAttrGroups = \true;
continue 2;
}
}

return $hasNewAttrGroups;
}

private function shouldSkip(PhpDocTagValueNode $phpDocTagValueNode, PhpDocInfo $phpDocInfo, string $annotationToAttributeTag) : bool
{
$doctrineAnnotationTagValueNode = $phpDocInfo->getByAnnotationClass($annotationToAttributeTag);
if ($phpDocTagValueNode !== $doctrineAnnotationTagValueNode) {
return \true;
}

return !$phpDocTagValueNode instanceof DoctrineAnnotationTagValueNode;
}

/**
* @param Class_ $node
*/
private function resolveOperations(DoctrineAnnotationTagValueNode $tagValue, Node $node): void
{
$values = $tagValue->getValues();

foreach ($this->operationTypes as $type) {
if (isset($values[$type])) {
$operations = $this->normalizeOperations($values[$type]->getValuesWithExplicitSilentAndWithoutQuotes());
foreach ($operations as $name => $arguments) {
$node->attrGroups[] = $this->createOperationAttributeGroup($type, $name, $arguments);
}
// Remove collectionOperations|itemOperations from Tag values
$tagValue->removeValue($type);
}
}
}
}
Loading