Skip to content

Commit e6e01b0

Browse files
authored
Generate API Client (#43)
* Generate API Client [wip] * Finish draft
1 parent 0536d29 commit e6e01b0

29 files changed

+813
-74
lines changed

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"cs:tests": "vendor/bin/php-cs-fixer fix tests",
1717
"cs": ["@cs:src", "@cs:tests"],
1818
"phpstan": "vendor/bin/phpstan analyse src tests bin",
19-
"php-parser-dump": "vendor/bin/php-parse tests/fixtures/NestedDto.php"
19+
"php-parser-dump": "vendor/bin/php-parse tests/Fixtures/NestedDto.php"
2020
},
2121
"require": {
2222
"php": ">=8.0",

src/Ast/Converter.php

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
use PhpParser\Parser;
99
use PhpParser\ParserFactory;
1010
use Riverwaysoft\DtoConverter\ClassFilter\ClassFilterInterface;
11+
use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpoint;
12+
use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpointList;
1113
use Riverwaysoft\DtoConverter\Dto\DtoList;
1214
use Riverwaysoft\DtoConverter\Dto\PhpType\PhpTypeFactory;
1315

@@ -21,35 +23,37 @@ class Converter
2123
private PhpTypeFactory $phpTypeFactory;
2224

2325
public function __construct(
24-
private ?ClassFilterInterface $classFilter = null,
26+
private ?ClassFilterInterface $dtoClassFilter = null,
2527
) {
2628
$this->phpTypeFactory = new PhpTypeFactory();
2729
$this->parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
2830
$this->phpDocTypeParser = new PhpDocTypeParser($this->phpTypeFactory);
2931
}
3032

3133
/** @param string[]|iterable $listings */
32-
public function convert(iterable $listings): DtoList
34+
public function convert(iterable $listings): ConverterResult
3335
{
3436
$dtoList = new DtoList();
37+
$apiEndpointList = new ApiEndpointList();
3538

3639
foreach ($listings as $listing) {
37-
$dtoList->merge($this->normalize($listing));
40+
$this->normalize($listing, $dtoList, $apiEndpointList);
3841
}
3942

40-
return $dtoList;
43+
return new ConverterResult(
44+
dtoList: $dtoList,
45+
apiEndpointList: $apiEndpointList,
46+
);
4147
}
4248

43-
private function normalize(string $code): DtoList
49+
private function normalize(string $code, DtoList $dtoList, ApiEndpointList $apiEndpointList): void
4450
{
4551
$ast = $this->parser->parse($code);
46-
$dtoList = new DtoList();
47-
$visitor = new AstVisitor($dtoList, $this->phpDocTypeParser, $this->phpTypeFactory, $this->classFilter);
48-
52+
$dtoVisitor = new DtoVisitor($dtoList, $this->phpDocTypeParser, $this->phpTypeFactory, $this->dtoClassFilter);
53+
$symfonyControllerVisitor = new SymfonyControllerVisitor('DtoEndpoint', $apiEndpointList, $this->phpTypeFactory);
4954
$traverser = new NodeTraverser();
50-
$traverser->addVisitor($visitor);
55+
$traverser->addVisitor($dtoVisitor);
56+
$traverser->addVisitor($symfonyControllerVisitor);
5157
$traverser->traverse($ast);
52-
53-
return $dtoList;
5458
}
5559
}

src/Ast/ConverterResult.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Riverwaysoft\DtoConverter\Ast;
6+
7+
use Riverwaysoft\DtoConverter\Dto\ApiClient\ApiEndpointList;
8+
use Riverwaysoft\DtoConverter\Dto\DtoList;
9+
10+
class ConverterResult
11+
{
12+
public function __construct(
13+
public DtoList $dtoList,
14+
public ApiEndpointList|null $apiEndpointList = null
15+
) {
16+
}
17+
}

src/Ast/AstVisitor.php renamed to src/Ast/DtoVisitor.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
use Riverwaysoft\DtoConverter\Dto\PhpType\PhpTypeInterface;
1919
use Riverwaysoft\DtoConverter\Dto\PhpType\PhpUnionType;
2020

21-
class AstVisitor extends NodeVisitorAbstract
21+
class DtoVisitor extends NodeVisitorAbstract
2222
{
2323
public function __construct(
2424
private DtoList $dtoList,
@@ -137,7 +137,7 @@ private function createDtoType(Class_|Enum_ $node): void
137137
}
138138
}
139139

140-
$this->dtoList->addDto(new DtoType(
140+
$this->dtoList->add(new DtoType(
141141
name: $node->name->name,
142142
expressionType: $expressionType,
143143
properties: $properties,
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
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+
}

src/Bridge/ApiPlatform/ApiPlatformInputTypeResolver.php

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use Riverwaysoft\DtoConverter\Dto\PhpType\PhpUnknownType;
1313
use Riverwaysoft\DtoConverter\Language\UnknownTypeResolver\UnknownTypeResolverInterface;
1414
use Riverwaysoft\DtoConverter\Language\UnsupportedTypeException;
15+
use Webmozart\Assert\Assert;
1516

1617
class ApiPlatformInputTypeResolver implements UnknownTypeResolverInterface
1718
{
@@ -23,13 +24,15 @@ public function __construct(
2324
) {
2425
}
2526

26-
public function supports(PhpUnknownType $type, DtoType $dto, DtoList $dtoList): bool
27+
public function supports(PhpUnknownType $type, DtoType|null $dto, DtoList $dtoList): bool
2728
{
28-
return $this->isApiPlatformInput($dto) && $this->isPropertyTypeClass($type) && !$this->isInput($type);
29+
return $dto && $this->isApiPlatformInput($dto) && $this->isPropertyTypeClass($type) && !$this->isInput($type);
2930
}
3031

31-
public function resolve(PhpUnknownType $type, DtoType $dto, DtoList $dtoList): string|PhpTypeInterface
32+
public function resolve(PhpUnknownType $type, DtoType|null $dto, DtoList $dtoList): string|PhpTypeInterface
3233
{
34+
Assert::notNull($dto, 'ApiPlatformInputTypeResolver should be called only for DTO. It was called for generating API Client');
35+
3336
if ($this->isPropertyEnum($type)) {
3437
if (!$dtoList->hasDtoWithType($type->getName())) {
3538
throw UnsupportedTypeException::forType($type, $dto->getName());

src/ClassFilter/DtoEndpoint.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Riverwaysoft\DtoConverter\ClassFilter;
6+
7+
#[\Attribute(\Attribute::TARGET_METHOD)]
8+
class DtoEndpoint
9+
{
10+
public function __construct(
11+
public string|null $returnMany,
12+
public string|null $returnOne,
13+
) {
14+
}
15+
}

src/Cli/ConvertCommand.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5151
return Command::SUCCESS;
5252
}
5353

54-
$normalized = $this->converter->convert($files);
55-
$outputFiles = $this->languageGenerator->generate($normalized);
54+
$converterResult = $this->converter->convert($files);
55+
$outputFiles = $this->languageGenerator->generate($converterResult);
5656

5757
foreach ($outputFiles as $outputFile) {
5858
$outputAbsolutePath = rtrim($to, '/') . '/' . $outputFile->getRelativeName();

src/Dto/ApiClient/ApiEndpoint.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Riverwaysoft\DtoConverter\Dto\ApiClient;
6+
7+
use Riverwaysoft\DtoConverter\Dto\PhpType\PhpTypeInterface;
8+
9+
class ApiEndpoint implements \JsonSerializable
10+
{
11+
public function __construct(
12+
public string $route,
13+
public ApiEndpointMethod $method,
14+
public ?PhpTypeInterface $input,
15+
public ?PhpTypeInterface $output,
16+
/** @var string[] */
17+
public array $routeParams = [],
18+
) {
19+
}
20+
21+
public function jsonSerialize(): mixed
22+
{
23+
return [
24+
'route' => $this->route,
25+
'routeParams' => $this->routeParams,
26+
'method' => $this->method->getType(),
27+
'input' => $this->input,
28+
'output' => $this->output,
29+
];
30+
}
31+
}

0 commit comments

Comments
 (0)