|  | 
|  | 1 | +<?php | 
|  | 2 | + | 
|  | 3 | +declare(strict_types=1); | 
|  | 4 | + | 
|  | 5 | +namespace Riverwaysoft\DtoConverter\Ast; | 
|  | 6 | + | 
|  | 7 | +use PhpParser\Node; | 
|  | 8 | +use PhpParser\Node\Attribute; | 
|  | 9 | +use PhpParser\Node\Stmt\ClassMethod; | 
|  | 10 | +use PhpParser\NodeVisitorAbstract; | 
|  | 11 | +use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpoint; | 
|  | 12 | +use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpointList; | 
|  | 13 | +use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpointMethod; | 
|  | 14 | +use Riverwaysoft\DtoConverter\Dto\PhpType\PhpBaseType; | 
|  | 15 | +use Riverwaysoft\DtoConverter\Dto\PhpType\PhpListType; | 
|  | 16 | +use Riverwaysoft\DtoConverter\Dto\PhpType\PhpTypeFactory; | 
|  | 17 | + | 
|  | 18 | +class SymfonyControllerVisitor extends NodeVisitorAbstract | 
|  | 19 | +{ | 
|  | 20 | +    public function __construct( | 
|  | 21 | +        private string $attribute, | 
|  | 22 | +        private ApiEndpointList $apiEndpointList, | 
|  | 23 | +        private PhpTypeFactory $phpTypeFactory, | 
|  | 24 | +    ) { | 
|  | 25 | +    } | 
|  | 26 | + | 
|  | 27 | +    public function leaveNode(Node $node) | 
|  | 28 | +    { | 
|  | 29 | +        if ($node instanceof ClassMethod && $this->findAttribute($node, $this->attribute)) { | 
|  | 30 | +            $this->createApiEndpoint($node); | 
|  | 31 | +        } | 
|  | 32 | + | 
|  | 33 | +        return null; | 
|  | 34 | +    } | 
|  | 35 | + | 
|  | 36 | +    private function findAttribute(ClassMethod|Node\Param $node, string $name): Attribute|null | 
|  | 37 | +    { | 
|  | 38 | +        $attrGroups = $node instanceof Node\Param ? $node->attrGroups : $node->getAttrGroups(); | 
|  | 39 | + | 
|  | 40 | +        foreach ($attrGroups as $attrGroup) { | 
|  | 41 | +            foreach ($attrGroup->attrs as $attr) { | 
|  | 42 | +                if (in_array(needle: $name, haystack: $attr->name->parts)) { | 
|  | 43 | +                    return $attr; | 
|  | 44 | +                } | 
|  | 45 | +            } | 
|  | 46 | +        } | 
|  | 47 | + | 
|  | 48 | +        return null; | 
|  | 49 | +    } | 
|  | 50 | + | 
|  | 51 | +    private function getAttributeArgumentByName(Attribute $attribute, string $name): ?Node\Arg | 
|  | 52 | +    { | 
|  | 53 | +        foreach ($attribute->args as $arg) { | 
|  | 54 | +            if ($arg->name?->name === $name) { | 
|  | 55 | +                return $arg; | 
|  | 56 | +            } | 
|  | 57 | +        } | 
|  | 58 | + | 
|  | 59 | +        return null; | 
|  | 60 | +    } | 
|  | 61 | + | 
|  | 62 | +    private function createApiEndpoint(ClassMethod $node): void | 
|  | 63 | +    { | 
|  | 64 | +        $routeAttribute = $this->findAttribute($node, 'Route'); | 
|  | 65 | + | 
|  | 66 | +        if (!$routeAttribute) { | 
|  | 67 | +            throw new \Exception('#[DtoEndpoint] is used on a method, that does not have #[Route] attribute'); | 
|  | 68 | +        } | 
|  | 69 | + | 
|  | 70 | +        $route = null; | 
|  | 71 | +        if ($routeAttribute->args[0]->name === null) { | 
|  | 72 | +            $routeNode = $routeAttribute->args[0]->value; | 
|  | 73 | +            if ($routeNode instanceof Node\Scalar\String_) { | 
|  | 74 | +                $route = $routeNode->value; | 
|  | 75 | +            } | 
|  | 76 | +        } | 
|  | 77 | + | 
|  | 78 | +        if (!$route) { | 
|  | 79 | +            $nameArg = $this->getAttributeArgumentByName($routeAttribute, 'name'); | 
|  | 80 | +            if ($nameArg?->value instanceof Node\Scalar\String_) { | 
|  | 81 | +                $route = $nameArg->value->value; | 
|  | 82 | +            } else { | 
|  | 83 | +                throw new \Exception('Could not find route path. Make sure your route looks like this #[Route(\'/api/users\')] or #[Route(name: \'/api/users/\')]'); | 
|  | 84 | +            } | 
|  | 85 | +        } | 
|  | 86 | + | 
|  | 87 | +        $method = null; | 
|  | 88 | +        $methodArg = $this->getAttributeArgumentByName($routeAttribute, 'methods'); | 
|  | 89 | +        if ($methodArg) { | 
|  | 90 | +            if ($methodArg->value instanceof Node\Expr\Array_) { | 
|  | 91 | +                if (count($methodArg->value->items) > 1) { | 
|  | 92 | +                    throw new \Exception('At the moment argument "methods" should have only 1 item'); | 
|  | 93 | +                } | 
|  | 94 | +                $methodString = $methodArg->value->items[0]->value; | 
|  | 95 | +                if ($methodString instanceof Node\Scalar\String_) { | 
|  | 96 | +                    $method = $methodString->value; | 
|  | 97 | +                } | 
|  | 98 | +            } elseif ($methodArg->value instanceof Node\Scalar\String_) { | 
|  | 99 | +                $method = $methodArg->value->value; | 
|  | 100 | +            } else { | 
|  | 101 | +                throw new \Exception('Only array argument "methods" is supported'); | 
|  | 102 | +            } | 
|  | 103 | +        } | 
|  | 104 | + | 
|  | 105 | +        if (!$method) { | 
|  | 106 | +            throw new \Exception('#[Route()] argument "methods" is required'); | 
|  | 107 | +        } | 
|  | 108 | + | 
|  | 109 | +        $dtoReturnAttribute = $this->findAttribute($node, 'DtoEndpoint'); | 
|  | 110 | +        if (!$dtoReturnAttribute) { | 
|  | 111 | +            throw new \Exception('Should not be reached, checked earlier'); | 
|  | 112 | +        } | 
|  | 113 | + | 
|  | 114 | +        $outputType = PhpBaseType::null(); | 
|  | 115 | +        if ($arg = $this->getAttributeArgumentByName($dtoReturnAttribute, 'returnOne')) { | 
|  | 116 | +            if (!($arg->value instanceof Node\Expr\ClassConstFetch)) { | 
|  | 117 | +                throw new \Exception('Argument of returnOne should be a class string'); | 
|  | 118 | +            } | 
|  | 119 | +            $outputType = $this->phpTypeFactory->create($arg->value->class->parts[0]); | 
|  | 120 | +        } | 
|  | 121 | +        if ($arg = $this->getAttributeArgumentByName($dtoReturnAttribute, 'returnMany')) { | 
|  | 122 | +            if (!($arg->value instanceof Node\Expr\ClassConstFetch)) { | 
|  | 123 | +                throw new \Exception('Argument of returnMany should be a class string'); | 
|  | 124 | +            } | 
|  | 125 | +            $outputType = new PhpListType($this->phpTypeFactory->create($arg->value->class->parts[0])); | 
|  | 126 | +        } | 
|  | 127 | + | 
|  | 128 | +        $inputType = null; | 
|  | 129 | +        $routeParams = $this->parseRoute($route); | 
|  | 130 | +        /** @var string[] $excessiveRouteParams */ | 
|  | 131 | +        $excessiveRouteParams = array_flip($routeParams); | 
|  | 132 | +        foreach ($node->params as $param) { | 
|  | 133 | +            $maybeDtoInputAttribute = $this->findAttribute($param, 'Input'); | 
|  | 134 | +            if ($maybeDtoInputAttribute) { | 
|  | 135 | +                if ($inputType) { | 
|  | 136 | +                    throw new \Exception('Multiple #[Input] on controller action are not supported'); | 
|  | 137 | +                } | 
|  | 138 | +                $inputType = $this->phpTypeFactory->create($param->type->parts[0]); | 
|  | 139 | +            } | 
|  | 140 | + | 
|  | 141 | +            if (isset($excessiveRouteParams[$param->var->name])) { | 
|  | 142 | +                unset($excessiveRouteParams[$param->var->name]); | 
|  | 143 | +            } | 
|  | 144 | +        } | 
|  | 145 | + | 
|  | 146 | +        if (!empty($excessiveRouteParams)) { | 
|  | 147 | +            throw new \Exception(sprintf( | 
|  | 148 | +                'Route %s has parameter %s, but there are no method params with this name. Available parameters: %s', | 
|  | 149 | +                $route, | 
|  | 150 | +                array_key_first($excessiveRouteParams), | 
|  | 151 | +                implode(', ', array_map( | 
|  | 152 | +                    fn (Node\Param $param): string => $param->var->name, | 
|  | 153 | +                    $node->params, | 
|  | 154 | +                )) | 
|  | 155 | +            )); | 
|  | 156 | +        } | 
|  | 157 | + | 
|  | 158 | +        $this->apiEndpointList->add(new ApiEndpoint( | 
|  | 159 | +            route: $route, | 
|  | 160 | +            method: ApiEndpointMethod::fromString($method), | 
|  | 161 | +            input: $inputType, | 
|  | 162 | +            output: $outputType, | 
|  | 163 | +            routeParams: $routeParams, | 
|  | 164 | +        )); | 
|  | 165 | +    } | 
|  | 166 | + | 
|  | 167 | +    /** @return string[] */ | 
|  | 168 | +    private function parseRoute(string $route): array | 
|  | 169 | +    { | 
|  | 170 | +        $pattern = '/\{([^\/}]+)\}/'; | 
|  | 171 | +        /** @var string[] $params */ | 
|  | 172 | +        $params = []; | 
|  | 173 | + | 
|  | 174 | +        preg_match_all($pattern, $route, $matches); | 
|  | 175 | +        foreach ($matches[1] as $param) { | 
|  | 176 | +            $params[] = $param; | 
|  | 177 | +        } | 
|  | 178 | + | 
|  | 179 | +        return $params; | 
|  | 180 | +    } | 
|  | 181 | +} | 
0 commit comments