From 8cdff9c788eccf115d5f5064a60829513d42625f Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 09:34:02 +0300 Subject: [PATCH 01/38] inferring params from calls to request object --- src/Infer/Analyzer/MethodAnalyzer.php | 7 +- src/Infer/Definition/ClassDefinition.php | 4 +- src/Infer/Handler/IndexBuildingHandler.php | 25 +++ .../SimpleTypeGetters/ScalarTypeGetter.php | 5 + src/Infer/TypeInferer.php | 2 + src/Support/Generator/Parameter.php | 19 ++- src/Support/Generator/Schema.php | 1 + src/Support/Generator/Types/Type.php | 16 ++ src/Support/IndexBuilders/Bag.php | 17 ++ .../RequestParametersBuilder.php | 151 ++++++++++++++++++ .../RequestBodyExtension.php | 8 + .../RulesExtractor/ValidateCallExtractor.php | 1 + src/Support/RouteInfo.php | 12 +- src/Support/Type/Literal/LiteralFloatType.php | 20 +++ .../RequestBodyExtensionTest.php | 55 +++++++ 15 files changed, 337 insertions(+), 6 deletions(-) create mode 100644 src/Infer/Handler/IndexBuildingHandler.php create mode 100644 src/Support/IndexBuilders/Bag.php create mode 100644 src/Support/IndexBuilders/RequestParametersBuilder.php create mode 100644 src/Support/Type/Literal/LiteralFloatType.php diff --git a/src/Infer/Analyzer/MethodAnalyzer.php b/src/Infer/Analyzer/MethodAnalyzer.php index 3b21495f..e09d4c57 100644 --- a/src/Infer/Analyzer/MethodAnalyzer.php +++ b/src/Infer/Analyzer/MethodAnalyzer.php @@ -5,6 +5,7 @@ use Dedoc\Scramble\Infer\Context; use Dedoc\Scramble\Infer\Definition\ClassDefinition; use Dedoc\Scramble\Infer\Definition\FunctionLikeDefinition; +use Dedoc\Scramble\Infer\Handler\IndexBuildingHandler; use Dedoc\Scramble\Infer\Reflector\ClassReflector; use Dedoc\Scramble\Infer\Scope\Index; use Dedoc\Scramble\Infer\Scope\NodeTypesResolver; @@ -25,11 +26,12 @@ public function __construct( ) { } - public function analyze(FunctionLikeDefinition $methodDefinition) + public function analyze(FunctionLikeDefinition $methodDefinition, array $indexBuilders = []) { $this->traverseClassMethod( [$this->getClassReflector()->getMethod($methodDefinition->type->name)->getAstNode()], $methodDefinition, + $indexBuilders, ); $methodDefinition = $this->index @@ -46,7 +48,7 @@ private function getClassReflector(): ClassReflector return ClassReflector::make($this->classDefinition->name); } - private function traverseClassMethod(array $nodes, FunctionLikeDefinition $methodDefinition) + private function traverseClassMethod(array $nodes, FunctionLikeDefinition $methodDefinition, array $indexBuilders = []) { $traverser = new NodeTraverser; @@ -57,6 +59,7 @@ private function traverseClassMethod(array $nodes, FunctionLikeDefinition $metho $nameResolver, new Scope($this->index, new NodeTypesResolver(), new ScopeContext($this->classDefinition), $nameResolver), Context::getInstance()->extensionsBroker->extensions, + [new IndexBuildingHandler($indexBuilders)], )); $node = (new NodeFinder()) diff --git a/src/Infer/Definition/ClassDefinition.php b/src/Infer/Definition/ClassDefinition.php index ae079772..9affd5be 100644 --- a/src/Infer/Definition/ClassDefinition.php +++ b/src/Infer/Definition/ClassDefinition.php @@ -43,7 +43,7 @@ public function isChildOf(string $className) return $this->isInstanceOf($className) && $this->name !== $className; } - public function getMethodDefinition(string $name, Scope $scope = new GlobalScope) + public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = []) { if (! array_key_exists($name, $this->methods)) { return null; @@ -55,7 +55,7 @@ public function getMethodDefinition(string $name, Scope $scope = new GlobalScope $this->methods[$name] = (new MethodAnalyzer( $scope->index, $this - ))->analyze($methodDefinition); + ))->analyze($methodDefinition, $indexBuilders); } $methodScope = new Scope( diff --git a/src/Infer/Handler/IndexBuildingHandler.php b/src/Infer/Handler/IndexBuildingHandler.php new file mode 100644 index 00000000..77202fc7 --- /dev/null +++ b/src/Infer/Handler/IndexBuildingHandler.php @@ -0,0 +1,25 @@ +indexBuilders as $indexBuilder) { + $indexBuilder->afterAnalyzedNode($scope, $node); + } + } +} diff --git a/src/Infer/SimpleTypeGetters/ScalarTypeGetter.php b/src/Infer/SimpleTypeGetters/ScalarTypeGetter.php index b22ae764..01cfa2e6 100644 --- a/src/Infer/SimpleTypeGetters/ScalarTypeGetter.php +++ b/src/Infer/SimpleTypeGetters/ScalarTypeGetter.php @@ -2,6 +2,7 @@ namespace Dedoc\Scramble\Infer\SimpleTypeGetters; +use Dedoc\Scramble\Support\Type\Literal\LiteralFloatType; use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; use Dedoc\Scramble\Support\Type\Type; @@ -20,6 +21,10 @@ public function __invoke(Node\Scalar $node): Type return new LiteralIntegerType($node->value); } + if ($node instanceof Node\Scalar\DNumber) { + return new LiteralFloatType($node->value); + } + return new UnknownType('Cannot get type from scalar'); } } diff --git a/src/Infer/TypeInferer.php b/src/Infer/TypeInferer.php index e0a442bb..e691e00a 100644 --- a/src/Infer/TypeInferer.php +++ b/src/Infer/TypeInferer.php @@ -37,6 +37,7 @@ public function __construct( private FileNameResolver $nameResolver, private ?Scope $scope = null, array $extensions = [], + array $handlers = [], ) { $this->handlers = [ new FunctionLikeHandler(), @@ -56,6 +57,7 @@ public function __construct( fn ($ext) => $ext instanceof ExpressionExceptionExtension, ))), new PhpDocHandler(), + ...$handlers, ]; } diff --git a/src/Support/Generator/Parameter.php b/src/Support/Generator/Parameter.php index a4ef0592..34123367 100644 --- a/src/Support/Generator/Parameter.php +++ b/src/Support/Generator/Parameter.php @@ -8,6 +8,7 @@ class Parameter /** * Possible values are "query", "header", "path" or "cookie". + * @var "query"|"header"|"path"|"cookie". */ public string $in; @@ -18,6 +19,9 @@ class Parameter /** @var array|scalar|null|MissingExample */ public $example; + /** @var array|scalar|null|MissingExample */ + public $default; + public bool $deprecated = false; public bool $allowEmptyValue = false; @@ -28,7 +32,9 @@ public function __construct(string $name, string $in) { $this->name = $name; $this->in = $in; + $this->example = new MissingExample; + $this->default = new MissingExample; if ($this->in === 'path') { $this->required = true; @@ -55,7 +61,11 @@ public function toArray(): array $result['schema'] = $this->schema->toArray(); } - return array_merge($result, $this->example instanceof MissingExample ? [] : ['example' => $this->example]); + return array_merge( + $result, + $this->example instanceof MissingExample ? [] : ['example' => $this->example], + $this->default instanceof MissingExample ? [] : ['default' => $this->default], + ); } public function required(bool $required) @@ -79,6 +89,13 @@ public function setSchema(?Schema $schema): self return $this; } + public function default($default) + { + $this->default = $default; + + return $this; + } + public function description(string $description) { $this->description = $description; diff --git a/src/Support/Generator/Schema.php b/src/Support/Generator/Schema.php index 24759a18..125b6956 100644 --- a/src/Support/Generator/Schema.php +++ b/src/Support/Generator/Schema.php @@ -46,6 +46,7 @@ public static function createFromParameters(array $parameters) $paramType->setDescription($parameter->description); $paramType->example($parameter->example); + $paramType->default($parameter->default); $type->addProperty($parameter->name, $paramType); }) diff --git a/src/Support/Generator/Types/Type.php b/src/Support/Generator/Types/Type.php index d2949bfc..9327518f 100644 --- a/src/Support/Generator/Types/Type.php +++ b/src/Support/Generator/Types/Type.php @@ -18,6 +18,9 @@ abstract class Type /** @var array|scalar|null|MissingExample */ public $example; + /** @var array|scalar|null|MissingExample */ + public $default; + /** @var array */ public $examples = []; @@ -29,6 +32,7 @@ public function __construct(string $type) { $this->type = $type; $this->example = new MissingExample; + $this->default = new MissingExample; } public function nullable(bool $nullable) @@ -53,6 +57,7 @@ public function addProperties(Type $fromType) $this->enum = $fromType->enum; $this->description = $fromType->description; $this->example = $fromType->example; + $this->default = $fromType->default; return $this; } @@ -67,6 +72,7 @@ public function toArray() 'enum' => count($this->enum) ? $this->enum : null, ]), $this->example instanceof MissingExample ? [] : ['example' => $this->example], + $this->default instanceof MissingExample ? [] : ['default' => $this->default], count( $examples = collect($this->examples) ->reject(fn ($example) => $example instanceof MissingExample) @@ -100,6 +106,16 @@ public function example($example) return $this; } + /** + * @param array|scalar|null|MissingExample $default + */ + public function default($default) + { + $this->default = $default; + + return $this; + } + /** * @param array $examples */ diff --git a/src/Support/IndexBuilders/Bag.php b/src/Support/IndexBuilders/Bag.php new file mode 100644 index 00000000..33051597 --- /dev/null +++ b/src/Support/IndexBuilders/Bag.php @@ -0,0 +1,17 @@ +data[$key] = $value; + + return $this; + } +} diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php new file mode 100644 index 00000000..c519aba8 --- /dev/null +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -0,0 +1,151 @@ +expr instanceof Node\Expr\MethodCall) { + return; + } + + $methodCallNode = $node->expr; + + $varType = $scope->getType($methodCallNode->var); + + if (! $varType->isInstanceOf(Request::class)) { + return; + } + + if (! $name = $this->getNameNodeValue($scope, $methodCallNode->name)) { + return; + } + + if (! ($parameterName = TypeHelper::getArgType($scope, $methodCallNode->args, ['key', 0])->value ?? null)) { + return; + } + + $parameter = Parameter::make($parameterName, 'query'/* @todo: this is just a temp solution */); + + [$parameterType, $parameterDefaultFromMethodCall] = match ($name) { + 'integer' => $this->makeIntegerParameter($scope, $methodCallNode), + 'float' => $this->makeFloatParameter($scope, $methodCallNode), + 'boolean' => $this->makeBooleanParameter($scope, $methodCallNode), + 'string', 'str' => $this->makeStringParameter($scope, $methodCallNode), + default => [null, null], + }; + + if (! $parameterType) { + return; + } + + $parameter + ->description($this->makeDescriptionFromComments($node)) + ->setSchema(Schema::fromType( + app(TypeTransformer::class)->transform($parameterType) + )) + ->default($parameterDefaultFromMethodCall ?? new MissingExample); + + // @todo: query + // @todo: get/input/post/? + + $this->bag->set($parameterName, $parameter); + } + + private function getNameNodeValue(Scope $scope, Node $nameNode) + { + if ($nameNode instanceof Node\Identifier) { + return $nameNode->name; + } + + $type = $scope->getType($nameNode); + if (! $type instanceof LiteralStringType) { + return null; + } + + return $type->value; + } + + private function makeIntegerParameter(Scope $scope, Node $node) + { + return [ + new IntegerType, + TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralIntegerType(0))->value ?? null, + ]; + } + + private function makeFloatParameter(Scope $scope, Node $node) + { + return [ + new FloatType, + TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralFloatType(0))->value ?? null + ]; + } + + private function makeBooleanParameter(Scope $scope, Node $node) + { + return [ + new BooleanType, + TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralBooleanType(false))->value ?? null + ]; + } + + private function makeStringParameter(Scope $scope, Node $node) + { + return [ + new StringType, + TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null + ]; + } + + private function makeDescriptionFromComments(Node\Stmt\Expression $node) + { + if ($node->getComments()) { + $docText = collect($node->getComments()) + ->map(fn (Comment $c) => $c->getReformattedText()) + ->join("\n"); + + return (string) Str::of($docText)->replace(['//', ' * ', '/*', '*/'], '')->trim(); + } + + /* + * @todo: consider adding only @param annotation support, + * so when description is taken only if comment is marked with @param + */ + if ($node->getDocComment()) { + return (string) Str::of($node->getDocComment()->getReformattedText()) + ->replace(['//', ' * ', '/*', '*/'], '') + ->trim(); + } + + return ''; + } +} diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 5333f414..8a6d17fc 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -26,9 +26,17 @@ public function handle(Operation $operation, RouteInfo $routeInfo) { $description = Str::of($routeInfo->phpDoc()->getAttribute('description')); + /* + * Making sure to analyze the route. + * @todo rename the method + */ + $routeInfo->getMethodType(); + try { $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); + $bodyParams = [...$bodyParams, ...array_values($routeInfo->requestParametersFromCalls->data)]; + $mediaType = $this->getMediaType($operation, $routeInfo, $bodyParams); if (count($bodyParams)) { diff --git a/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php b/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php index e9a0e9ac..c9551c1e 100644 --- a/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php +++ b/src/Support/OperationExtensions/RulesExtractor/ValidateCallExtractor.php @@ -32,6 +32,7 @@ public function node(): ?ValidationNodesResult fn (Node $node) => $node instanceof Node\Expr\MethodCall && $node->var instanceof Node\Expr\Variable && is_a($this->getPossibleParamType($methodNode, $node->var), Request::class, true) + && $node->name instanceof Node\Identifier && $node->name->name === 'validate' ); $validationRules = $callToValidate->args[0] ?? null; diff --git a/src/Support/RouteInfo.php b/src/Support/RouteInfo.php index ecc3725f..1a09f029 100644 --- a/src/Support/RouteInfo.php +++ b/src/Support/RouteInfo.php @@ -6,6 +6,8 @@ use Dedoc\Scramble\Infer\Reflector\MethodReflector; use Dedoc\Scramble\Infer\Services\FileParser; use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper; +use Dedoc\Scramble\Support\IndexBuilders\Bag; +use Dedoc\Scramble\Support\IndexBuilders\RequestParametersBuilder; use Dedoc\Scramble\Support\Type\ArrayItemType_; use Dedoc\Scramble\Support\Type\BooleanType; use Dedoc\Scramble\Support\Type\FloatType; @@ -41,11 +43,14 @@ class RouteInfo private Infer $infer; + public readonly Bag $requestParametersFromCalls; + public function __construct(Route $route, FileParser $fileParser, Infer $infer) { $this->route = $route; $this->parser = $fileParser; $this->infer = $infer; + $this->requestParametersFromCalls = new Bag(); } public function isClassBased(): bool @@ -209,6 +214,9 @@ public function getCodeReturnType() ->getMethodReturnType($this->methodName()); } + /** + * @todo Maybe better name is needed as this method performs method analysis, indexes building, etc. + */ public function getMethodType(): ?FunctionType { if (! $this->isClassBased() || ! $this->reflectionMethod()) { @@ -221,7 +229,9 @@ public function getMethodType(): ?FunctionType /* * Here the final resolution of the method types may happen. */ - $this->methodType = $def->getMethodDefinition($this->methodName())->type; + $this->methodType = $def->getMethodDefinition($this->methodName(), indexBuilders: [ + new RequestParametersBuilder($this->requestParametersFromCalls), + ])->type; } return $this->methodType; diff --git a/src/Support/Type/Literal/LiteralFloatType.php b/src/Support/Type/Literal/LiteralFloatType.php new file mode 100644 index 00000000..7474b3ee --- /dev/null +++ b/src/Support/Type/Literal/LiteralFloatType.php @@ -0,0 +1,20 @@ +value = $value; + } + + public function toString(): string + { + return parent::toString()."($this->value)"; + } +} diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index f50674c2..2354ebad 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -80,3 +80,58 @@ public function index(Illuminate\Http\Request $request) $request->validate(['foo' => 'file']); } } + +it('extracts parameters, their defaults, and descriptions from calling request parameters retrieving methods with scalar types', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_scalar_types::class, 'index']); + }); + + expect($schema = $openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']) + ->toHaveLength(2) + ->and($schema['properties']) + ->toBe([ + 'count' => [ + 'type' => 'integer', + 'description' => 'How many things are there.', + 'default' => 10, + ], + 'weight' => [ + 'type' => 'number', + 'default' => 0.5, + ], + 'is_foo' => [ + 'type' => 'boolean', + 'default' => false, + ], + 'name' => [ + 'type' => 'string', + 'default' => 'John Doe', + ], + ]) + ->and($schema['required'] ?? null) + ->toBeNull(); +}); +class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_scalar_types +{ + public function index(Illuminate\Http\Request $request) + { + // How many things are there. + $request->integer('count', 10); + + $request->float('weight', 0.5); + + $request->boolean('is_foo'); + + $request->string('name', 'John Doe'); + +// $request->enum('status', RequestBodyExtensionTest__Status_Params_Extraction::class); +// +// $request->query('in_query'); + } +} +enum RequestBodyExtensionTest__Status_Params_Extraction: string { + case Clubs = 'clubs'; + case Diamonds = 'diamonds'; + case Hearts = 'hearts'; + case Spades = 'spades'; +} From aed04fa42d5b4549c8372eb941c8c194fe131033 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 09:49:14 +0300 Subject: [PATCH 02/38] enum inference from type support --- .../RequestParametersBuilder.php | 14 ++++++++ .../RequestBodyExtensionTest.php | 34 +++++++++++++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index c519aba8..a069b4ef 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -14,6 +14,7 @@ use Dedoc\Scramble\Support\Type\Literal\LiteralFloatType; use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; +use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\TypeHelper; use Illuminate\Http\Request; @@ -60,6 +61,7 @@ public function afterAnalyzedNode(Scope $scope, Node $node) 'float' => $this->makeFloatParameter($scope, $methodCallNode), 'boolean' => $this->makeBooleanParameter($scope, $methodCallNode), 'string', 'str' => $this->makeStringParameter($scope, $methodCallNode), + 'enum' => $this->makeEnumParameter($scope, $methodCallNode), default => [null, null], }; @@ -126,6 +128,18 @@ private function makeStringParameter(Scope $scope, Node $node) ]; } + private function makeEnumParameter(Scope $scope, Node $node) + { + if (!$className = TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null) { + return [null, null]; + } + + return [ + new ObjectType($className), + null, + ]; + } + private function makeDescriptionFromComments(Node\Stmt\Expression $node) { if ($node->getComments()) { diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 2354ebad..377d682b 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -123,9 +123,39 @@ public function index(Illuminate\Http\Request $request) $request->boolean('is_foo'); $request->string('name', 'John Doe'); +// $request->query('in_query'); + } +} + +it('extracts parameters, their defaults, and descriptions from calling request parameters retrieving methods with enum', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_enum::class, 'index']); + }); + + expect($properties = $openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']['properties']) + ->toHaveLength(1) + ->and($properties['status']) + ->toBe([ + '$ref' => '#/components/schemas/RequestBodyExtensionTest__Status_Params_Extraction' + ]) + ->and($openApiDocument['components']['schemas']['RequestBodyExtensionTest__Status_Params_Extraction']) + ->toBe([ + 'type' => 'string', + 'enum' => [ + 'clubs', + 'diamonds', + 'hearts', + 'spades', + ], + 'title' => 'RequestBodyExtensionTest__Status_Params_Extraction' + ]); +}); +class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_enum +{ + public function index(Illuminate\Http\Request $request) + { + $request->enum('status', RequestBodyExtensionTest__Status_Params_Extraction::class); -// $request->enum('status', RequestBodyExtensionTest__Status_Params_Extraction::class); -// // $request->query('in_query'); } } From 7f8748ec2e4d77dd6224e3900c3cf8cb85d925c2 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 11:04:05 +0300 Subject: [PATCH 03/38] added query method support --- src/Support/Generator/Parameter.php | 2 ++ .../RequestParametersBuilder.php | 12 +++++++++ .../RequestBodyExtension.php | 13 +++++++--- .../RequestBodyExtensionTest.php | 26 ++++++++++++++++++- 4 files changed, 49 insertions(+), 4 deletions(-) diff --git a/src/Support/Generator/Parameter.php b/src/Support/Generator/Parameter.php index 34123367..da82eb07 100644 --- a/src/Support/Generator/Parameter.php +++ b/src/Support/Generator/Parameter.php @@ -4,6 +4,8 @@ class Parameter { + use WithAttributes; + public string $name; /** diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index a069b4ef..dfbd8d15 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -14,6 +14,7 @@ use Dedoc\Scramble\Support\Type\Literal\LiteralFloatType; use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; +use Dedoc\Scramble\Support\Type\MixedType; use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\TypeHelper; @@ -62,6 +63,7 @@ public function afterAnalyzedNode(Scope $scope, Node $node) 'boolean' => $this->makeBooleanParameter($scope, $methodCallNode), 'string', 'str' => $this->makeStringParameter($scope, $methodCallNode), 'enum' => $this->makeEnumParameter($scope, $methodCallNode), + 'query' => $this->makeQueryParameter($scope, $methodCallNode, $parameter), default => [null, null], }; @@ -140,6 +142,16 @@ private function makeEnumParameter(Scope $scope, Node $node) ]; } + private function makeQueryParameter(Scope $scope, Node $node, Parameter $parameter) + { + $parameter->setAttribute('isInQuery', true); + + return [ + new MixedType, + TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null, + ]; + } + private function makeDescriptionFromComments(Node\Stmt\Expression $node) { if ($node->getComments()) { diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 8a6d17fc..b61d4910 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -35,11 +35,17 @@ public function handle(Operation $operation, RouteInfo $routeInfo) try { $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); - $bodyParams = [...$bodyParams, ...array_values($routeInfo->requestParametersFromCalls->data)]; + $allParams = [...$bodyParams, ...array_values($routeInfo->requestParametersFromCalls->data)]; + [$queryParams, $bodyParams] = collect($allParams) + ->partition(function (Parameter $parameter) { + return $parameter->getAttribute('isInQuery'); + }); + $queryParams = $queryParams->toArray(); + $bodyParams = $bodyParams->toArray(); - $mediaType = $this->getMediaType($operation, $routeInfo, $bodyParams); + $mediaType = $this->getMediaType($operation, $routeInfo, $allParams); - if (count($bodyParams)) { + if (count($allParams)) { if (! in_array($operation->method, static::HTTP_METHODS_WITHOUT_REQUEST_BODY)) { $operation->addRequestBodyObject( RequestBodyObject::make()->setContent($mediaType, Schema::createFromParameters($bodyParams)) @@ -47,6 +53,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) } else { $operation->addParameters($bodyParams); } + $operation->addParameters($queryParams); } elseif (! in_array($operation->method, static::HTTP_METHODS_WITHOUT_REQUEST_BODY)) { $operation ->addRequestBodyObject( diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 377d682b..5b9e9c3e 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -123,7 +123,6 @@ public function index(Illuminate\Http\Request $request) $request->boolean('is_foo'); $request->string('name', 'John Doe'); -// $request->query('in_query'); } } @@ -165,3 +164,28 @@ enum RequestBodyExtensionTest__Status_Params_Extraction: string { case Hearts = 'hearts'; case Spades = 'spades'; } + + +it('extracts parameters, their defaults, and descriptions from calling request parameters retrieving methods with query', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_query::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['parameters']) + ->toHaveLength(1) + ->toBe([[ + 'name' => 'in_query', + 'in' => 'query', + 'schema' => [ + 'type' => 'string', + ], + 'default' => 'foo', + ]]); +}); +class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_query +{ + public function index(Illuminate\Http\Request $request) + { + $request->query('in_query', 'foo'); + } +} From 9a9d139f6921b698d193f4db66cb20c6484b11b4 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sun, 7 Apr 2024 08:04:48 +0000 Subject: [PATCH 04/38] Fix styling --- src/Infer/Handler/IndexBuildingHandler.php | 3 ++- src/Support/Generator/Parameter.php | 1 + src/Support/IndexBuilders/Bag.php | 3 ++- .../IndexBuilders/RequestParametersBuilder.php | 8 ++++---- .../RequestBodyExtensionTest.php | 14 +++++++------- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/Infer/Handler/IndexBuildingHandler.php b/src/Infer/Handler/IndexBuildingHandler.php index 77202fc7..f707ddfa 100644 --- a/src/Infer/Handler/IndexBuildingHandler.php +++ b/src/Infer/Handler/IndexBuildingHandler.php @@ -9,7 +9,8 @@ class IndexBuildingHandler { public function __construct( private array $indexBuilders, - ){} + ) { + } public function shouldHandle($node) { diff --git a/src/Support/Generator/Parameter.php b/src/Support/Generator/Parameter.php index da82eb07..c9b57702 100644 --- a/src/Support/Generator/Parameter.php +++ b/src/Support/Generator/Parameter.php @@ -10,6 +10,7 @@ class Parameter /** * Possible values are "query", "header", "path" or "cookie". + * * @var "query"|"header"|"path"|"cookie". */ public string $in; diff --git a/src/Support/IndexBuilders/Bag.php b/src/Support/IndexBuilders/Bag.php index 33051597..876f0c7f 100644 --- a/src/Support/IndexBuilders/Bag.php +++ b/src/Support/IndexBuilders/Bag.php @@ -6,7 +6,8 @@ class Bag { public function __construct( public array $data = [] - ) {} + ) { + } public function set(string $key, $value) { diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index dfbd8d15..71d6c8c3 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -110,7 +110,7 @@ private function makeFloatParameter(Scope $scope, Node $node) { return [ new FloatType, - TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralFloatType(0))->value ?? null + TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralFloatType(0))->value ?? null, ]; } @@ -118,7 +118,7 @@ private function makeBooleanParameter(Scope $scope, Node $node) { return [ new BooleanType, - TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralBooleanType(false))->value ?? null + TypeHelper::getArgType($scope, $node->args, ['default', 1], new LiteralBooleanType(false))->value ?? null, ]; } @@ -126,13 +126,13 @@ private function makeStringParameter(Scope $scope, Node $node) { return [ new StringType, - TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null + TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null, ]; } private function makeEnumParameter(Scope $scope, Node $node) { - if (!$className = TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null) { + if (! $className = TypeHelper::getArgType($scope, $node->args, ['default', 1])->value ?? null) { return [null, null]; } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 5b9e9c3e..f6c15009 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -135,7 +135,7 @@ public function index(Illuminate\Http\Request $request) ->toHaveLength(1) ->and($properties['status']) ->toBe([ - '$ref' => '#/components/schemas/RequestBodyExtensionTest__Status_Params_Extraction' + '$ref' => '#/components/schemas/RequestBodyExtensionTest__Status_Params_Extraction', ]) ->and($openApiDocument['components']['schemas']['RequestBodyExtensionTest__Status_Params_Extraction']) ->toBe([ @@ -146,8 +146,8 @@ public function index(Illuminate\Http\Request $request) 'hearts', 'spades', ], - 'title' => 'RequestBodyExtensionTest__Status_Params_Extraction' - ]); + 'title' => 'RequestBodyExtensionTest__Status_Params_Extraction', + ]); }); class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_enum { @@ -155,17 +155,17 @@ public function index(Illuminate\Http\Request $request) { $request->enum('status', RequestBodyExtensionTest__Status_Params_Extraction::class); -// $request->query('in_query'); + // $request->query('in_query'); } } -enum RequestBodyExtensionTest__Status_Params_Extraction: string { +enum RequestBodyExtensionTest__Status_Params_Extraction: string +{ case Clubs = 'clubs'; case Diamonds = 'diamonds'; case Hearts = 'hearts'; case Spades = 'spades'; } - it('extracts parameters, their defaults, and descriptions from calling request parameters retrieving methods with query', function () { $openApiDocument = generateForRoute(function () { return RouteFacade::post('api/test', [RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_query::class, 'index']); @@ -180,7 +180,7 @@ enum RequestBodyExtensionTest__Status_Params_Extraction: string { 'type' => 'string', ], 'default' => 'foo', - ]]); + ]]); }); class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_query { From 74f5441b3cc3155f80db24448534e5932386f73d Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 11:41:17 +0300 Subject: [PATCH 05/38] ability to ignore param inference --- .../RequestParametersBuilder.php | 17 +++++++++++++++-- .../RequestBodyExtensionTest.php | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index 71d6c8c3..c3bc5c61 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -22,6 +22,7 @@ use Illuminate\Support\Str; use PhpParser\Comment; use PhpParser\Node; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; class RequestParametersBuilder { @@ -55,6 +56,10 @@ public function afterAnalyzedNode(Scope $scope, Node $node) return; } + if ($this->shouldIgnoreParameter($node)) { + return; + } + $parameter = Parameter::make($parameterName, 'query'/* @todo: this is just a temp solution */); [$parameterType, $parameterDefaultFromMethodCall] = match ($name) { @@ -159,7 +164,7 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) ->map(fn (Comment $c) => $c->getReformattedText()) ->join("\n"); - return (string) Str::of($docText)->replace(['//', ' * ', '/*', '*/'], '')->trim(); + return (string) Str::of($docText)->replace(['//', ' * ', '/**', '/*', '*/'], '')->trim(); } /* @@ -168,10 +173,18 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) */ if ($node->getDocComment()) { return (string) Str::of($node->getDocComment()->getReformattedText()) - ->replace(['//', ' * ', '/*', '*/'], '') + ->replace(['//', ' * ', '/**', '/*', '*/'], '') ->trim(); } return ''; } + + private function shouldIgnoreParameter(Node\Stmt\Expression $node) + { + /** @var PhpDocNode $phpDoc */ + $phpDoc = $node->getAttribute('parsedPhpDoc'); + + return !! $phpDoc->getTagsByName('@ignoreParam'); + } } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index f6c15009..78d576af 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -154,8 +154,6 @@ class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with public function index(Illuminate\Http\Request $request) { $request->enum('status', RequestBodyExtensionTest__Status_Params_Extraction::class); - - // $request->query('in_query'); } } enum RequestBodyExtensionTest__Status_Params_Extraction: string @@ -189,3 +187,20 @@ public function index(Illuminate\Http\Request $request) $request->query('in_query', 'foo'); } } + +it('ignores parameter with @ignoreParam doc', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__ignores_parameter_with_ignore_param_doc::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']['properties'] ?? []) + ->toHaveLength(0); +}); +class RequestBodyExtensionTest__ignores_parameter_with_ignore_param_doc +{ + public function index(Illuminate\Http\Request $request) + { + /** @ignoreParam */ + $request->integer('foo', 10); + } +} From f368442b6e7aa7d09f59a0a8b7fd3b06c4a278a7 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 11:58:09 +0300 Subject: [PATCH 06/38] added @default doc support --- src/Support/Helpers/ExamplesExtractor.php | 13 +++++-- .../RequestParametersBuilder.php | 38 +++++++++++++------ .../RequestBodyExtensionTest.php | 17 +++++++++ 3 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/Support/Helpers/ExamplesExtractor.php b/src/Support/Helpers/ExamplesExtractor.php index bc0c908c..de840eb6 100644 --- a/src/Support/Helpers/ExamplesExtractor.php +++ b/src/Support/Helpers/ExamplesExtractor.php @@ -11,18 +11,19 @@ class ExamplesExtractor { public function __construct( - private ?PhpDocNode $docNode + private ?PhpDocNode $docNode, + private string $tagName = '@example', ) { } - public static function make(?PhpDocNode $docNode) + public static function make(?PhpDocNode $docNode, string $tagName = '@example') { - return new self($docNode); + return new self($docNode, $tagName); } public function extract(bool $preferString = false) { - if (! count($examples = $this->docNode->getTagsByName('@example'))) { + if (! count($examples = $this->docNode->getTagsByName($this->tagName))) { return []; } @@ -52,6 +53,10 @@ private function getTypedExampleValue($exampleValue, bool $preferString = false) $exampleValue = $exampleValue === 'true'; } elseif (is_numeric($exampleValue) && ! $preferString) { $exampleValue = floatval($exampleValue); + + if (floor($exampleValue) == $exampleValue) { + $exampleValue = intval($exampleValue); + } } return $exampleValue; diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index c3bc5c61..240252e4 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -7,6 +7,7 @@ use Dedoc\Scramble\Support\Generator\Parameter; use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\TypeTransformer; +use Dedoc\Scramble\Support\Helpers\ExamplesExtractor; use Dedoc\Scramble\Support\Type\BooleanType; use Dedoc\Scramble\Support\Type\FloatType; use Dedoc\Scramble\Support\Type\IntegerType; @@ -62,7 +63,7 @@ public function afterAnalyzedNode(Scope $scope, Node $node) $parameter = Parameter::make($parameterName, 'query'/* @todo: this is just a temp solution */); - [$parameterType, $parameterDefaultFromMethodCall] = match ($name) { + [$parameterType, $parameterDefault] = match ($name) { 'integer' => $this->makeIntegerParameter($scope, $methodCallNode), 'float' => $this->makeFloatParameter($scope, $methodCallNode), 'boolean' => $this->makeBooleanParameter($scope, $methodCallNode), @@ -76,12 +77,16 @@ public function afterAnalyzedNode(Scope $scope, Node $node) return; } + if ($parameterDefaultFromDoc = $this->getParameterDefaultFromPhpDoc($node)) { + $parameterDefault = $parameterDefaultFromDoc; + } + $parameter ->description($this->makeDescriptionFromComments($node)) ->setSchema(Schema::fromType( app(TypeTransformer::class)->transform($parameterType) )) - ->default($parameterDefaultFromMethodCall ?? new MissingExample); + ->default($parameterDefault ?? new MissingExample); // @todo: query // @todo: get/input/post/? @@ -159,6 +164,17 @@ private function makeQueryParameter(Scope $scope, Node $node, Parameter $paramet private function makeDescriptionFromComments(Node\Stmt\Expression $node) { + /* + * @todo: consider adding only @param annotation support, + * so when description is taken only if comment is marked with @param + */ + if ($node->getDocComment()) { + /** @var PhpDocNode $phpDoc */ + $phpDoc = $node->getAttribute('parsedPhpDoc'); + + return trim($phpDoc->getAttribute('summary').' '.$phpDoc->getAttribute('description')); + } + if ($node->getComments()) { $docText = collect($node->getComments()) ->map(fn (Comment $c) => $c->getReformattedText()) @@ -167,16 +183,6 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) return (string) Str::of($docText)->replace(['//', ' * ', '/**', '/*', '*/'], '')->trim(); } - /* - * @todo: consider adding only @param annotation support, - * so when description is taken only if comment is marked with @param - */ - if ($node->getDocComment()) { - return (string) Str::of($node->getDocComment()->getReformattedText()) - ->replace(['//', ' * ', '/**', '/*', '*/'], '') - ->trim(); - } - return ''; } @@ -187,4 +193,12 @@ private function shouldIgnoreParameter(Node\Stmt\Expression $node) return !! $phpDoc->getTagsByName('@ignoreParam'); } + + private function getParameterDefaultFromPhpDoc(Node\Stmt\Expression $node) + { + /** @var PhpDocNode $phpDoc */ + $phpDoc = $node->getAttribute('parsedPhpDoc'); + + return ExamplesExtractor::make($phpDoc, '@default')->extract()[0] ?? null; + } } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 78d576af..3f233c64 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -204,3 +204,20 @@ public function index(Illuminate\Http\Request $request) $request->integer('foo', 10); } } + +it('uses and overrides default param value when it is provided manually in doc', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__uses_and_overrides_default_param_value_when_it_is_provided_manually_in_doc::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']['properties']['foo']['default']) + ->toBe(15); +}); +class RequestBodyExtensionTest__uses_and_overrides_default_param_value_when_it_is_provided_manually_in_doc +{ + public function index(Illuminate\Http\Request $request) + { + /** @default 15 */ + $request->integer('foo', 10); + } +} From 532e0daa4f1b13818bbca3134d6e06c5fb90f780 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 12:04:56 +0300 Subject: [PATCH 07/38] @query and @default tags support --- src/Support/Helpers/ExamplesExtractor.php | 2 +- .../RequestParametersBuilder.php | 23 ++++++++++++------ .../RequestBodyExtensionTest.php | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/src/Support/Helpers/ExamplesExtractor.php b/src/Support/Helpers/ExamplesExtractor.php index de840eb6..ecfa67fb 100644 --- a/src/Support/Helpers/ExamplesExtractor.php +++ b/src/Support/Helpers/ExamplesExtractor.php @@ -23,7 +23,7 @@ public static function make(?PhpDocNode $docNode, string $tagName = '@example') public function extract(bool $preferString = false) { - if (! count($examples = $this->docNode->getTagsByName($this->tagName))) { + if (! count($examples = $this->docNode?->getTagsByName($this->tagName) ?? [])) { return []; } diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index 240252e4..2066541b 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -81,6 +81,8 @@ public function afterAnalyzedNode(Scope $scope, Node $node) $parameterDefault = $parameterDefaultFromDoc; } + $this->checkExplicitParameterPlacementInQuery($node, $parameter); + $parameter ->description($this->makeDescriptionFromComments($node)) ->setSchema(Schema::fromType( @@ -168,10 +170,7 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) * @todo: consider adding only @param annotation support, * so when description is taken only if comment is marked with @param */ - if ($node->getDocComment()) { - /** @var PhpDocNode $phpDoc */ - $phpDoc = $node->getAttribute('parsedPhpDoc'); - + if ($phpDoc = $node->getAttribute('parsedPhpDoc')) { return trim($phpDoc->getAttribute('summary').' '.$phpDoc->getAttribute('description')); } @@ -188,17 +187,27 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) private function shouldIgnoreParameter(Node\Stmt\Expression $node) { - /** @var PhpDocNode $phpDoc */ + /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); - return !! $phpDoc->getTagsByName('@ignoreParam'); + return !! $phpDoc?->getTagsByName('@ignoreParam'); } private function getParameterDefaultFromPhpDoc(Node\Stmt\Expression $node) { - /** @var PhpDocNode $phpDoc */ + /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); return ExamplesExtractor::make($phpDoc, '@default')->extract()[0] ?? null; } + + private function checkExplicitParameterPlacementInQuery(Node\Stmt\Expression $node, Parameter $parameter) + { + /** @var PhpDocNode|null $phpDoc */ + $phpDoc = $node->getAttribute('parsedPhpDoc'); + + if(!! $phpDoc?->getTagsByName('@query')) { + $parameter->setAttribute('isInQuery', true); + } + } } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 3f233c64..98516e82 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -221,3 +221,27 @@ public function index(Illuminate\Http\Request $request) $request->integer('foo', 10); } } + +it('allows explicitly specifying parameter placement in query manually in doc', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__allows_explicitly_specifying_parameter_placement_in_query_manually_in_doc::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']['properties'] ?? []) + ->toBeEmpty() + ->and($openApiDocument['paths']['/test']['post']['parameters']) + ->toBe([[ + 'name' => 'foo', + 'in' => 'query', + 'schema' => ['type' => 'integer'], + 'default' => 10, + ]]); +}); +class RequestBodyExtensionTest__allows_explicitly_specifying_parameter_placement_in_query_manually_in_doc +{ + public function index(Illuminate\Http\Request $request) + { + /** @query */ + $request->integer('foo', 10); + } +} From 7ccb054da82a9dccb354c5d8553325c808cb3337 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sun, 7 Apr 2024 09:05:24 +0000 Subject: [PATCH 08/38] Fix styling --- src/Support/IndexBuilders/RequestParametersBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index 2066541b..8d09c13c 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -190,7 +190,7 @@ private function shouldIgnoreParameter(Node\Stmt\Expression $node) /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); - return !! $phpDoc?->getTagsByName('@ignoreParam'); + return (bool) $phpDoc?->getTagsByName('@ignoreParam'); } private function getParameterDefaultFromPhpDoc(Node\Stmt\Expression $node) @@ -206,7 +206,7 @@ private function checkExplicitParameterPlacementInQuery(Node\Stmt\Expression $no /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); - if(!! $phpDoc?->getTagsByName('@query')) { + if ((bool) $phpDoc?->getTagsByName('@query')) { $parameter->setAttribute('isInQuery', true); } } From d71f07a98f0abcfa5be8ac58a8e1f0d63f796170 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 12:27:25 +0300 Subject: [PATCH 09/38] default and query support when provided in validation rules --- .../RulesExtractor/RulesToParameter.php | 8 +++++ .../RequestBodyExtensionTest.php | 34 +++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php b/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php index db6c1ca7..75eca0ec 100644 --- a/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php +++ b/src/Support/OperationExtensions/RulesExtractor/RulesToParameter.php @@ -89,6 +89,14 @@ private function applyDocsInfo(Parameter $parameter) $parameter->example($examples[0]); } + if ($default = ExamplesExtractor::make($this->docNode, '@default')->extract(preferString: $parameter->schema->type instanceof StringType)) { + $parameter->default($default[0]); + } + + if ($this->docNode->getTagsByName('@query')) { + $parameter->setAttribute('isInQuery', true); + } + return $parameter; } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 98516e82..25daa69a 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -245,3 +245,37 @@ public function index(Illuminate\Http\Request $request) $request->integer('foo', 10); } } + +it('allows specifying query position and default for params inferred from validation rules using validate method', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__allows_specifying_query_position_and_default_for_params_inferred_from_validation_rules_using_validate_method::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']['application/json']['schema']['properties']) + ->toBe([ + 'per_page' => [ + 'type' => 'integer', + 'default' => 10, + ] + ]) + ->and($openApiDocument['paths']['/test']['post']['parameters']) + ->toBe([[ + 'name' => 'all', + 'in' => 'query', + 'schema' => [ + 'type' => 'boolean', + ], + ]]); +}); +class RequestBodyExtensionTest__allows_specifying_query_position_and_default_for_params_inferred_from_validation_rules_using_validate_method +{ + public function index(Illuminate\Http\Request $request) + { + $request->validate([ + /** @default 10 */ + 'per_page' => 'integer', + /** @query */ + 'all' => 'boolean', + ]); + } +} From 59c84d0c66583d3d8da3bf7fc5230ac97cc995c7 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sun, 7 Apr 2024 09:27:56 +0000 Subject: [PATCH 10/38] Fix styling --- tests/Support/OperationExtensions/RequestBodyExtensionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 25daa69a..ba7fdff2 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -256,7 +256,7 @@ public function index(Illuminate\Http\Request $request) 'per_page' => [ 'type' => 'integer', 'default' => 10, - ] + ], ]) ->and($openApiDocument['paths']['/test']['post']['parameters']) ->toBe([[ From 0de6db2e77043628d1e2f6659b6c556e4f8c0c45 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 7 Apr 2024 12:32:38 +0300 Subject: [PATCH 11/38] added get, input, and post methods support --- src/Support/IndexBuilders/RequestParametersBuilder.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index 8d09c13c..e02a737b 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -67,9 +67,9 @@ public function afterAnalyzedNode(Scope $scope, Node $node) 'integer' => $this->makeIntegerParameter($scope, $methodCallNode), 'float' => $this->makeFloatParameter($scope, $methodCallNode), 'boolean' => $this->makeBooleanParameter($scope, $methodCallNode), - 'string', 'str' => $this->makeStringParameter($scope, $methodCallNode), 'enum' => $this->makeEnumParameter($scope, $methodCallNode), 'query' => $this->makeQueryParameter($scope, $methodCallNode, $parameter), + 'string', 'str', 'get', 'input', 'post' => $this->makeStringParameter($scope, $methodCallNode), default => [null, null], }; @@ -90,9 +90,6 @@ public function afterAnalyzedNode(Scope $scope, Node $node) )) ->default($parameterDefault ?? new MissingExample); - // @todo: query - // @todo: get/input/post/? - $this->bag->set($parameterName, $parameter); } From fed78ce3fcf1ce4fee9bf2b9a3b0b79ba7b596cf Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 24 Apr 2024 08:21:12 +0300 Subject: [PATCH 12/38] sort validation rules by nesting deepness before documenting them to make results consistent --- .../RulesExtractor/RulesToParameters.php | 4 +- .../ValidationRulesDocumentationTest.php | 39 +++++++++++++++++++ ...s_from_object_like_rules_heavy_case__1.yml | 2 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesToParameters.php b/src/Support/OperationExtensions/RulesExtractor/RulesToParameters.php index a5d0b7e5..0a6a7efe 100644 --- a/src/Support/OperationExtensions/RulesExtractor/RulesToParameters.php +++ b/src/Support/OperationExtensions/RulesExtractor/RulesToParameters.php @@ -48,7 +48,9 @@ public function handle() private function handleNested(Collection $parameters) { - [$nested, $parameters] = $parameters->partition(fn ($_, $key) => Str::contains($key, '.')); + [$nested, $parameters] = $parameters + ->sortBy(fn ($_, $key) => count(explode('.', $key))) + ->partition(fn ($_, $key) => Str::contains($key, '.')); $nestedParentsKeys = $nested->keys()->map(fn ($key) => explode('.', $key)[0]); diff --git a/tests/Generator/Request/ValidationRulesDocumentationTest.php b/tests/Generator/Request/ValidationRulesDocumentationTest.php index 44239bbd..25835ec6 100644 --- a/tests/Generator/Request/ValidationRulesDocumentationTest.php +++ b/tests/Generator/Request/ValidationRulesDocumentationTest.php @@ -30,3 +30,42 @@ ->and($params[3]) ->toMatchArray(['name' => 'email_confirmation']); }); + +it('works when last validation item is items array', function () { + $rules = [ + 'items.*.name' => 'required|string', + 'items.*.email'=> 'email', + 'items.*' => 'array', + 'items' => ['array', 'min:1', 'max:10'], + ]; + + $params = app()->make(RulesToParameters::class, ['rules' => $rules])->handle(); + + expect($params = collect($params)->map->toArray()->all()) + ->toBe([ + [ + "name" => "items", + "in" => "query", + "schema" => [ + "type" => "array", + "items" => [ + "type" => "object", + "properties" => [ + "name" => [ + "type" => "string" + ], + "email" => [ + "type" => "string", + "format" => "email", + ], + ], + "required" => [ + 0 => "name", + ], + ], + "minItems" => 1.0, + "maxItems" => 10.0, + ] + ] + ]); +}); diff --git a/tests/__snapshots__/ValidationRulesDocumentingTest__it_extract_rules_from_object_like_rules_heavy_case__1.yml b/tests/__snapshots__/ValidationRulesDocumentingTest__it_extract_rules_from_object_like_rules_heavy_case__1.yml index b1c71402..94845bf5 100644 --- a/tests/__snapshots__/ValidationRulesDocumentingTest__it_extract_rules_from_object_like_rules_heavy_case__1.yml +++ b/tests/__snapshots__/ValidationRulesDocumentingTest__it_extract_rules_from_object_like_rules_heavy_case__1.yml @@ -6,4 +6,4 @@ - name: channels in: query - schema: { type: object, properties: { publisher: { type: object, properties: { id: { type: [integer, 'null'] }, name: { type: [string, 'null'] } } }, channel_url: { type: string }, agency: { type: [object, 'null'], properties: { id: { type: [integer, 'null'] }, name: { type: [string, 'null'] } } } } } + schema: { type: object, properties: { channel_url: { type: string }, agency: { type: [object, 'null'], properties: { id: { type: [integer, 'null'] }, name: { type: [string, 'null'] } } }, publisher: { type: object, properties: { id: { type: [integer, 'null'] }, name: { type: [string, 'null'] } } } } } From 6bdc2dea9b71542ba6091ab245b61031709a637f Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Wed, 24 Apr 2024 05:21:41 +0000 Subject: [PATCH 13/38] Fix styling --- .../ValidationRulesDocumentationTest.php | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/tests/Generator/Request/ValidationRulesDocumentationTest.php b/tests/Generator/Request/ValidationRulesDocumentationTest.php index 25835ec6..5986b111 100644 --- a/tests/Generator/Request/ValidationRulesDocumentationTest.php +++ b/tests/Generator/Request/ValidationRulesDocumentationTest.php @@ -34,7 +34,7 @@ it('works when last validation item is items array', function () { $rules = [ 'items.*.name' => 'required|string', - 'items.*.email'=> 'email', + 'items.*.email' => 'email', 'items.*' => 'array', 'items' => ['array', 'min:1', 'max:10'], ]; @@ -44,28 +44,28 @@ expect($params = collect($params)->map->toArray()->all()) ->toBe([ [ - "name" => "items", - "in" => "query", - "schema" => [ - "type" => "array", - "items" => [ - "type" => "object", - "properties" => [ - "name" => [ - "type" => "string" + 'name' => 'items', + 'in' => 'query', + 'schema' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => [ + 'type' => 'string', ], - "email" => [ - "type" => "string", - "format" => "email", + 'email' => [ + 'type' => 'string', + 'format' => 'email', ], ], - "required" => [ - 0 => "name", + 'required' => [ + 0 => 'name', ], ], - "minItems" => 1.0, - "maxItems" => 10.0, - ] - ] + 'minItems' => 1.0, + 'maxItems' => 10.0, + ], + ], ]); }); From d0f1342e4b380464ce0b09eefad527dffecf095d Mon Sep 17 00:00:00 2001 From: hn-seoai Date: Mon, 6 May 2024 11:29:54 +0200 Subject: [PATCH 14/38] Fix shorthand ternaries trying to pass null as a Node instance --- src/Infer/Scope/Scope.php | 2 +- tests/Infer/Scope/ScopeTest.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Infer/Scope/Scope.php b/src/Infer/Scope/Scope.php index 5b0ac5ad..13eb86ed 100644 --- a/src/Infer/Scope/Scope.php +++ b/src/Infer/Scope/Scope.php @@ -55,7 +55,7 @@ public function getType(Node $node): Type if ($node instanceof Node\Expr\Ternary) { return Union::wrap([ - $this->getType($node->if), + $this->getType($node->if ?? $node->cond), $this->getType($node->else), ]); } diff --git a/tests/Infer/Scope/ScopeTest.php b/tests/Infer/Scope/ScopeTest.php index 4ca84d3b..4e7e74bf 100644 --- a/tests/Infer/Scope/ScopeTest.php +++ b/tests/Infer/Scope/ScopeTest.php @@ -17,4 +17,12 @@ function getStatementTypeForScopeTest(string $statement, array $extensions = []) })->with([ ['unknown() ? 1 : null', 'int(1)|null'], ['unknown() ? 1 : 1', 'int(1)'], + ['unknown() ?: 1', 'unknown|int(1)'], + ['(int) unknown() ?: 1', 'int|int(1)'], + ['1 ?: 1', 'int(1)'], + ['unknown() ? 1 : unknown()', 'int(1)|unknown'], + ['unknown() ? unknown() : unknown()', 'unknown'], + ['unknown() ?: unknown()', 'unknown'], + ['unknown() ?: true ?: 1', 'unknown|boolean(true)|int(1)'], + ['unknown() ?: unknown() ?: unknown()', 'unknown'], ]); From 275302686821f80992c8d29b8d4c87b3b8a5f3ae Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Tue, 14 May 2024 12:52:52 +0300 Subject: [PATCH 15/38] remove content from no-content responses --- src/Support/Generator/Response.php | 11 ++++++----- .../ResponseTypeToSchema.php | 15 +++++++++------ ...t__response____noContent___call_support__1.yml | 2 +- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/src/Support/Generator/Response.php b/src/Support/Generator/Response.php index d50638d0..de974ce3 100644 --- a/src/Support/Generator/Response.php +++ b/src/Support/Generator/Response.php @@ -37,13 +37,14 @@ public function toArray() 'description' => $this->description, ]; - $content = []; - foreach ($this->content as $mediaType => $schema) { - $content[$mediaType] = $schema ? ['schema' => $schema->toArray()] : (object) []; + if (isset($this->content)) { + $content = []; + foreach ($this->content ?? [] as $mediaType => $schema) { + $content[$mediaType] = $schema ? ['schema' => $schema->toArray()] : (object) []; + } + $result['content'] = $content; } - $result['content'] = $content; - return $result; } diff --git a/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php b/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php index d6f7eec0..ab260b8f 100644 --- a/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/ResponseTypeToSchema.php @@ -32,13 +32,16 @@ public function toResponse(Type $type) $emptyContent = ($type->templateTypes[0]->value ?? null) === ''; - return Response::make($code = $type->templateTypes[1]->value) - ->description($code === 204 ? 'No content' : '') - ->setContent( + $response = Response::make($code = $type->templateTypes[1]->value) + ->description($code === 204 ? 'No content' : ''); + + if (! $emptyContent) { + $response->setContent( 'application/json', // @todo: Some other response types are possible as well - $emptyContent - ? null - : Schema::fromType($this->openApiTransformer->transform($type->templateTypes[0])), + Schema::fromType($this->openApiTransformer->transform($type->templateTypes[0])), ); + } + + return $response; } } diff --git a/tests/__snapshots__/ResponseDocumentingTest__response____noContent___call_support__1.yml b/tests/__snapshots__/ResponseDocumentingTest__response____noContent___call_support__1.yml index 4d2d24fb..6a604499 100644 --- a/tests/__snapshots__/ResponseDocumentingTest__response____noContent___call_support__1.yml +++ b/tests/__snapshots__/ResponseDocumentingTest__response____noContent___call_support__1.yml @@ -5,4 +5,4 @@ info: servers: - { url: 'http://localhost/api' } paths: - /test: { get: { operationId: fooTest.index, tags: [Foo_Test], responses: { 204: { description: 'No content', content: { application/json: { } } } } } } + /test: { get: { operationId: fooTest.index, tags: [Foo_Test], responses: { 204: { description: 'No content' } } } } From 5520b59f167e7397c5c5c3fda065fed9c76ce193 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 15 May 2024 10:11:57 +0300 Subject: [PATCH 16/38] fix a horrible thing --- src/GeneratorConfig.php | 6 ++++-- src/Infer/Reflector/MethodReflector.php | 5 ++++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/GeneratorConfig.php b/src/GeneratorConfig.php index 59695882..6a852ac1 100644 --- a/src/GeneratorConfig.php +++ b/src/GeneratorConfig.php @@ -14,7 +14,7 @@ public function __construct( private ?Closure $routeResolver = null, private ?Closure $afterOpenApiGenerated = null, ) { - $this->routeResolver = $this->routeResolver ?? function (Route $route) { + $this->routeResolver = $this->routeResolver ?: function (Route $route) { $expectedDomain = $this->get('api_domain'); return Str::startsWith($route->uri, $this->get('api_path', 'api')) @@ -35,7 +35,9 @@ public function routes(?Closure $routeResolver = null) return $this->routeResolver; } - $this->routeResolver = $routeResolver; + if ($routeResolver) { + $this->routeResolver = $routeResolver; + } return $this; } diff --git a/src/Infer/Reflector/MethodReflector.php b/src/Infer/Reflector/MethodReflector.php index dcb0fff8..eff462ae 100644 --- a/src/Infer/Reflector/MethodReflector.php +++ b/src/Infer/Reflector/MethodReflector.php @@ -48,7 +48,10 @@ public function getReflection(): ReflectionMethod return new ReflectionMethod($this->className, $this->name); } - public function getAstNode(): ClassMethod + /** + * @todo: Think if this method can actually return `null` or it should fail. + */ + public function getAstNode(): ?ClassMethod { if (! $this->methodNode) { $className = class_basename($this->className); From 7bb8f76a078db510f3de0d05c616beeb9f42f75b Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 15 May 2024 11:04:07 +0300 Subject: [PATCH 17/38] fixed params from request method call being not inferred --- .../RequestParametersBuilder.php | 34 ++++++++++++------- .../RequestBodyExtension.php | 10 +++++- .../RequestBodyExtensionTest.php | 2 +- 3 files changed, 32 insertions(+), 14 deletions(-) diff --git a/src/Support/IndexBuilders/RequestParametersBuilder.php b/src/Support/IndexBuilders/RequestParametersBuilder.php index e02a737b..ca514cbf 100644 --- a/src/Support/IndexBuilders/RequestParametersBuilder.php +++ b/src/Support/IndexBuilders/RequestParametersBuilder.php @@ -23,6 +23,7 @@ use Illuminate\Support\Str; use PhpParser\Comment; use PhpParser\Node; +use PhpParser\NodeAbstract; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; class RequestParametersBuilder @@ -33,16 +34,25 @@ public function __construct(public Bag $bag) public function afterAnalyzedNode(Scope $scope, Node $node) { - if (! $node instanceof Node\Stmt\Expression) { + // @todo: Find more general approach to get a comment related to the node + [$commentHolderNode, $methodCallNode] = match ($node::class) { + Node\Stmt\Expression::class => [ + $node, + $node->expr instanceof Node\Expr\Assign ? $node->expr->expr : $node->expr, + ], + Node\Arg::class => [$node, $node->value], + Node\ArrayItem::class => [$node, $node->value], + default => [null, null], + }; + + if (! $commentHolderNode) { return; } - if (! $node->expr instanceof Node\Expr\MethodCall) { + if (! $methodCallNode instanceof Node\Expr\MethodCall) { return; } - $methodCallNode = $node->expr; - $varType = $scope->getType($methodCallNode->var); if (! $varType->isInstanceOf(Request::class)) { @@ -57,7 +67,7 @@ public function afterAnalyzedNode(Scope $scope, Node $node) return; } - if ($this->shouldIgnoreParameter($node)) { + if ($this->shouldIgnoreParameter($commentHolderNode)) { return; } @@ -77,14 +87,14 @@ public function afterAnalyzedNode(Scope $scope, Node $node) return; } - if ($parameterDefaultFromDoc = $this->getParameterDefaultFromPhpDoc($node)) { + if ($parameterDefaultFromDoc = $this->getParameterDefaultFromPhpDoc($commentHolderNode)) { $parameterDefault = $parameterDefaultFromDoc; } - $this->checkExplicitParameterPlacementInQuery($node, $parameter); + $this->checkExplicitParameterPlacementInQuery($commentHolderNode, $parameter); $parameter - ->description($this->makeDescriptionFromComments($node)) + ->description($this->makeDescriptionFromComments($commentHolderNode)) ->setSchema(Schema::fromType( app(TypeTransformer::class)->transform($parameterType) )) @@ -161,7 +171,7 @@ private function makeQueryParameter(Scope $scope, Node $node, Parameter $paramet ]; } - private function makeDescriptionFromComments(Node\Stmt\Expression $node) + private function makeDescriptionFromComments(NodeAbstract $node) { /* * @todo: consider adding only @param annotation support, @@ -182,7 +192,7 @@ private function makeDescriptionFromComments(Node\Stmt\Expression $node) return ''; } - private function shouldIgnoreParameter(Node\Stmt\Expression $node) + private function shouldIgnoreParameter(NodeAbstract $node) { /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); @@ -190,7 +200,7 @@ private function shouldIgnoreParameter(Node\Stmt\Expression $node) return (bool) $phpDoc?->getTagsByName('@ignoreParam'); } - private function getParameterDefaultFromPhpDoc(Node\Stmt\Expression $node) + private function getParameterDefaultFromPhpDoc(NodeAbstract $node) { /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); @@ -198,7 +208,7 @@ private function getParameterDefaultFromPhpDoc(Node\Stmt\Expression $node) return ExamplesExtractor::make($phpDoc, '@default')->extract()[0] ?? null; } - private function checkExplicitParameterPlacementInQuery(Node\Stmt\Expression $node, Parameter $parameter) + private function checkExplicitParameterPlacementInQuery(NodeAbstract $node, Parameter $parameter) { /** @var PhpDocNode|null $phpDoc */ $phpDoc = $node->getAttribute('parsedPhpDoc'); diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index b61d4910..0d6c1b44 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -35,7 +35,15 @@ public function handle(Operation $operation, RouteInfo $routeInfo) try { $bodyParams = $this->extractParamsFromRequestValidationRules($routeInfo->route, $routeInfo->methodNode()); - $allParams = [...$bodyParams, ...array_values($routeInfo->requestParametersFromCalls->data)]; + $bodyParamsNames = array_map(fn ($p) => $p->name, $bodyParams); + + $allParams = [ + ...$bodyParams, + ...array_filter( + array_values($routeInfo->requestParametersFromCalls->data), + fn ($p) => !in_array($p->name, $bodyParamsNames), + ), + ]; [$queryParams, $bodyParams] = collect($allParams) ->partition(function (Parameter $parameter) { return $parameter->getAttribute('isInQuery'); diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index ba7fdff2..3262dea1 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -116,7 +116,7 @@ class RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with public function index(Illuminate\Http\Request $request) { // How many things are there. - $request->integer('count', 10); + $param = $request->integer('count', 10); $request->float('weight', 0.5); From 4b4de4d5c56e346909acd77e9624ff31b3a03ec8 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Wed, 15 May 2024 08:04:37 +0000 Subject: [PATCH 18/38] Fix styling --- src/Support/OperationExtensions/RequestBodyExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 0d6c1b44..3d847883 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -41,7 +41,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ...$bodyParams, ...array_filter( array_values($routeInfo->requestParametersFromCalls->data), - fn ($p) => !in_array($p->name, $bodyParamsNames), + fn ($p) => ! in_array($p->name, $bodyParamsNames), ), ]; [$queryParams, $bodyParams] = collect($allParams) From cf4ba0c899c88a03b2846ff98cb60ca26e935557 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 15 May 2024 12:49:13 +0300 Subject: [PATCH 19/38] fixed serialized error --- src/GeneratorConfig.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/GeneratorConfig.php b/src/GeneratorConfig.php index 6a852ac1..39109d98 100644 --- a/src/GeneratorConfig.php +++ b/src/GeneratorConfig.php @@ -14,12 +14,6 @@ public function __construct( private ?Closure $routeResolver = null, private ?Closure $afterOpenApiGenerated = null, ) { - $this->routeResolver = $this->routeResolver ?: function (Route $route) { - $expectedDomain = $this->get('api_domain'); - - return Str::startsWith($route->uri, $this->get('api_path', 'api')) - && (! $expectedDomain || $route->getDomain() === $expectedDomain); - }; } public function config(array $config) @@ -32,7 +26,7 @@ public function config(array $config) public function routes(?Closure $routeResolver = null) { if (count(func_get_args()) === 0) { - return $this->routeResolver; + return $this->routeResolver ?: $this->defaultRoutesFilter(...); } if ($routeResolver) { @@ -42,6 +36,14 @@ public function routes(?Closure $routeResolver = null) return $this; } + private function defaultRoutesFilter(Route $route) + { + $expectedDomain = $this->get('api_domain'); + + return Str::startsWith($route->uri, $this->get('api_path', 'api')) + && (! $expectedDomain || $route->getDomain() === $expectedDomain); + } + public function afterOpenApiGenerated(?Closure $afterOpenApiGenerated = null) { if (count(func_get_args()) === 0) { From 673d6a0bf537593d0c706e6f95a4eb5be8e92bc9 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 15 May 2024 13:48:54 +0300 Subject: [PATCH 20/38] fixed serialization issue (maybe) --- src/Scramble.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Scramble.php b/src/Scramble.php index 72db999a..c6eb7813 100755 --- a/src/Scramble.php +++ b/src/Scramble.php @@ -89,7 +89,9 @@ public static function registerUiRoute(string $path, string $api = 'default'): R { $config = static::getGeneratorConfig($api); - return RouteFacade::get($path, function (Generator $generator) use ($config) { + return RouteFacade::get($path, function (Generator $generator) use ($api) { + $config = static::getGeneratorConfig($api); + return view('scramble::docs', [ 'spec' => $generator($config), 'config' => $config, @@ -102,7 +104,9 @@ public static function registerJsonSpecificationRoute(string $path, string $api { $config = static::getGeneratorConfig($api); - return RouteFacade::get($path, function (Generator $generator) use ($config) { + return RouteFacade::get($path, function (Generator $generator) use ($api) { + $config = static::getGeneratorConfig($api); + return response()->json($generator($config), options: JSON_PRETTY_PRINT); }) ->middleware($config->get('middleware', [RestrictedDocsAccess::class])); From 03469aeacf5a2f245cef66298bc3bcda174a0b16 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Wed, 15 May 2024 16:19:17 +0300 Subject: [PATCH 21/38] fix routes register booting --- src/ScrambleServiceProvider.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/ScrambleServiceProvider.php b/src/ScrambleServiceProvider.php index 61c70d7d..8b319f2e 100644 --- a/src/ScrambleServiceProvider.php +++ b/src/ScrambleServiceProvider.php @@ -10,6 +10,7 @@ use Dedoc\Scramble\Infer\Extensions\InferExtension; use Dedoc\Scramble\Infer\Scope\Index; use Dedoc\Scramble\Infer\Services\FileParser; +use Dedoc\Scramble\Support\DefaultCallback; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\AuthorizationExceptionToResponseExtension; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\HttpExceptionToResponseExtension; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\NotFoundExceptionToResponseExtension; @@ -39,6 +40,7 @@ use Dedoc\Scramble\Support\TypeToSchemaExtensions\LengthAwarePaginatorTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ModelToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResponseTypeToSchema; +use Illuminate\Support\Facades\Event; use PhpParser\ParserFactory; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -160,5 +162,11 @@ public function bootingPackage() Scramble::registerApi('default', config('scramble')) ->routes(Scramble::$routeResolver) ->afterOpenApiGenerated(Scramble::$openApiExtender); + + $this->app->booted(function () { + Scramble::getGeneratorConfig('default') + ->routes(Scramble::$routeResolver) + ->afterOpenApiGenerated(Scramble::$openApiExtender); + }); } } From f0b2ac615fce05afaef60880678060ac2a977b54 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Wed, 15 May 2024 13:19:49 +0000 Subject: [PATCH 22/38] Fix styling --- src/ScrambleServiceProvider.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ScrambleServiceProvider.php b/src/ScrambleServiceProvider.php index 8b319f2e..e99fb81a 100644 --- a/src/ScrambleServiceProvider.php +++ b/src/ScrambleServiceProvider.php @@ -10,7 +10,6 @@ use Dedoc\Scramble\Infer\Extensions\InferExtension; use Dedoc\Scramble\Infer\Scope\Index; use Dedoc\Scramble\Infer\Services\FileParser; -use Dedoc\Scramble\Support\DefaultCallback; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\AuthorizationExceptionToResponseExtension; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\HttpExceptionToResponseExtension; use Dedoc\Scramble\Support\ExceptionToResponseExtensions\NotFoundExceptionToResponseExtension; @@ -40,7 +39,6 @@ use Dedoc\Scramble\Support\TypeToSchemaExtensions\LengthAwarePaginatorTypeToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ModelToSchema; use Dedoc\Scramble\Support\TypeToSchemaExtensions\ResponseTypeToSchema; -use Illuminate\Support\Facades\Event; use PhpParser\ParserFactory; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; From c38fa352e5047e4259e33557ced381885224b4ba Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 17 May 2024 16:17:17 +0200 Subject: [PATCH 23/38] Improve inferring model property types, based on DB driver --- .../InferExtensions/ModelExtension.php | 42 ++++++++++++------- src/Support/ResponseExtractor/ModelInfo.php | 2 + 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 43cf134f..912a75fc 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -56,7 +56,7 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type if ($attribute = $info->get('attributes')->get($event->getName())) { $baseType = $this->getAttributeTypeFromEloquentCasts($attribute['cast'] ?? '') - ?? $this->getAttributeTypeFromDbColumnType($attribute['type'] ?? ''); + ?? $this->getAttributeTypeFromDbColumnType($attribute['type'], $attribute['driver']); if ($attribute['nullable']) { return Union::wrap([$baseType, new NullType()]); @@ -72,22 +72,34 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type throw new \LogicException('Should not happen'); } - private function getAttributeTypeFromDbColumnType(string $columnType): AbstractType + /** + * MySQL/MariaDB decimal is mapped to a string by PDO. + * Floating point numbers and decimals are all mapped to strings when using the pgsql driver. + */ + private function getAttributeTypeFromDbColumnType(?string $columnType, ?string $dbDriverName): AbstractType { - $type = Str::before($columnType, ' '); - $typeName = Str::before($type, '('); - - // @todo Fix to native types - $attributeType = match ($typeName) { - 'int', 'integer', 'bigint' => new IntegerType(), - 'float', 'double', 'decimal' => new FloatType(), - 'varchar', 'string', 'text', 'datetime' => new StringType(), // string, text - needed? - 'tinyint', 'bool', 'boolean' => new BooleanType(), // bool, boolean - needed? - 'json', 'array' => new ArrayType(), - default => new UnknownType("unimplemented DB column type [$type]"), - }; + if ($columnType === null) { + return new UnknownType(); + } + + $typeName = str($columnType) + ->before(' ') // strip modifiers from a type name such as `bigint unsigned` + ->before('(') // strip the length from a type name such as `tinyint(4)` + ->toString(); + + if (in_array($typeName, ['int', 'integer', 'tinyint', 'smallint', 'mediumint', 'bigint'])) { + return new IntegerType(); + } + + if ($dbDriverName === 'sqlite' && in_array($typeName, ['float', 'double', 'decimal'])) { + return new FloatType(); + } + + if (in_array($dbDriverName, ['mysql', 'mariadb']) && in_array($typeName, ['float', 'double'])) { + return new FloatType(); + } - return $attributeType; + return new StringType(); } /** diff --git a/src/Support/ResponseExtractor/ModelInfo.php b/src/Support/ResponseExtractor/ModelInfo.php index 4d3ff44a..9faef86b 100644 --- a/src/Support/ResponseExtractor/ModelInfo.php +++ b/src/Support/ResponseExtractor/ModelInfo.php @@ -163,6 +163,7 @@ protected function getAttributes($model) return collect($columns) ->values() ->map(fn ($column) => [ + 'driver' => $connection->getDriverName(), 'name' => $column['name'], 'type' => $column['type'], 'increments' => $column['auto_increment'], @@ -226,6 +227,7 @@ protected function getVirtualAttributes($model, $columns) }) ->reject(fn ($cast, $name) => $keyedColumns->has($name)) ->map(fn ($cast, $name) => [ + 'driver' => null, 'name' => $name, 'type' => null, 'increments' => false, From fa3890c27f2683c753e1149d9fb0dfb81e5b1a81 Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 17 May 2024 17:10:36 +0200 Subject: [PATCH 24/38] Add note about uninferred model attribute types --- src/Support/InferExtensions/ModelExtension.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 912a75fc..5387f16b 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -56,7 +56,8 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type if ($attribute = $info->get('attributes')->get($event->getName())) { $baseType = $this->getAttributeTypeFromEloquentCasts($attribute['cast'] ?? '') - ?? $this->getAttributeTypeFromDbColumnType($attribute['type'], $attribute['driver']); + ?? $this->getAttributeTypeFromDbColumnType($attribute['type'], $attribute['driver']) + ?? new UnknownType("Virtual attribute ({$attribute['name']}) type inference not supported."); if ($attribute['nullable']) { return Union::wrap([$baseType, new NullType()]); @@ -76,10 +77,10 @@ public function getPropertyType(PropertyFetchEvent $event): ?Type * MySQL/MariaDB decimal is mapped to a string by PDO. * Floating point numbers and decimals are all mapped to strings when using the pgsql driver. */ - private function getAttributeTypeFromDbColumnType(?string $columnType, ?string $dbDriverName): AbstractType + private function getAttributeTypeFromDbColumnType(?string $columnType, ?string $dbDriverName): ?AbstractType { if ($columnType === null) { - return new UnknownType(); + return null; } $typeName = str($columnType) From 0db6fe45b1d0cb6cf5d2e0082af1907c7ec9d60a Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Fri, 17 May 2024 21:08:09 +0300 Subject: [PATCH 25/38] added tests for routes-related code --- .gitignore | 1 + phpunit.xml.dist | 49 +++++++---------- tests/ScrambleTest.php | 117 +++++++++++++++++++++++++++++++++++++++++ tests/TestCase.php | 3 -- 4 files changed, 138 insertions(+), 32 deletions(-) create mode 100644 tests/ScrambleTest.php diff --git a/.gitignore b/.gitignore index 63dcf8ad..866f438a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ testbench.yaml vendor node_modules *-test.json +.phpunit.cache/ diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8586309..47467030 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,31 +1,22 @@ - - - - tests - - - - - ./src - - - - - - - - - + + + + tests + + + + + + + + + + + + + + ./src + + diff --git a/tests/ScrambleTest.php b/tests/ScrambleTest.php new file mode 100644 index 00000000..ef5897b6 --- /dev/null +++ b/tests/ScrambleTest.php @@ -0,0 +1,117 @@ +assertCount(2, $routes = Route::getRoutes()->getRoutes()); + + $this->assertRoutesAreCacheable($routes); + } + + /** @test */ + public function caches_default_routes() + { + $this->assertCount(2, $routes = Route::getRoutes()->getRoutes()); + + $this->assertRoutesAreCacheable($routes); + } + + /** @test */ + public function registers_default_api() + { + expect(Scramble::getGeneratorConfig('default'))->toBeTruthy(); + } + + /** @test */ + public function registers_routes_for_default_api() + { + expect($routes = Route::getRoutes()->getRoutes()) + ->toHaveCount(2) + ->and($routes[0]->uri)->toBe('docs/api') + ->and($routes[0]->methods)->toBe(['GET', 'HEAD']) + ->and($routes[1]->uri)->toBe('docs/api.json') + ->and($routes[1]->methods)->toBe(['GET', 'HEAD']); + } + + /** @test */ + #[DefineRoute('registerTestConsumerRoutes')] + public function filters_consumer_routes_with_config_file() + { + $generator = app(Generator::class); + + $doc = $generator(Scramble::getGeneratorConfig('default')); + + $this->assertEquals('http://localhost/api', $doc['servers'][0]['url']); + $this->assertEquals(['/a', '/b', '/c'], array_keys($doc['paths'])); + } + + /** @test */ + #[DefineRoute('registerTestConsumerRoutes')] + #[DefineEnvironment('withEmptyConfigApiPath')] + #[DefineEnvironment('withClosureAllRouteResolver')] + public function filters_consumer_routes_with_redefined_resolver_and_api_path_config_file() + { + $generator = app(Generator::class); + + $doc = $generator(Scramble::getGeneratorConfig('default')); + + $this->assertEquals('http://localhost', $doc['servers'][0]['url']); + $this->assertEquals(['/api/a', '/api/b', '/api/c', '/second-api/a', '/second-api/b', '/second-api/c'], array_keys($doc['paths'])); + } + + protected function withClosureAllRouteResolver() + { + Scramble::routes(fn () => true); + } + + protected function withEmptyConfigApiPath($app) + { + $app['config']->set('scramble.api_path', ''); + } + + protected function registerTestConsumerRoutes(Router $router) + { + $router->group(['prefix' => 'api'], function (Router $router) { + $router->get('a', [ScrambleTest_Controller::class, 'test']); + $router->get('b', [ScrambleTest_Controller::class, 'test']); + $router->get('c', [ScrambleTest_Controller::class, 'test']); + }); + + $router->group(['prefix' => 'second-api'], function (Router $router) { + $router->get('a', [ScrambleTest_Controller::class, 'test']); + $router->get('b', [ScrambleTest_Controller::class, 'test']); + $router->get('c', [ScrambleTest_Controller::class, 'test']); + }); + } + + private function assertRoutesAreCacheable($routes) + { + foreach ($routes as $route) { + $route->prepareForSerialization(); + } + + unserialize(serialize($routes)); + + expect(true)->toBeTrue(); + } +} + +class ScrambleTest_Controller +{ + public function test() {} +} diff --git a/tests/TestCase.php b/tests/TestCase.php index c6af3ac0..c31c2cce 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -41,9 +41,6 @@ protected function getPackageProviders($app) public function getEnvironmentSetUp($app) { config()->set('database.default', 'testing'); - - // $migration = include __DIR__.'/migrations/create_documentor_table.php.stub'; - // $migration->up(); } protected function defineDatabaseMigrations() From b7dfe21887cba743c607ee4f2a0270a9966dd5d0 Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Fri, 17 May 2024 18:08:39 +0000 Subject: [PATCH 26/38] Fix styling --- tests/ScrambleTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ScrambleTest.php b/tests/ScrambleTest.php index ef5897b6..91c69530 100644 --- a/tests/ScrambleTest.php +++ b/tests/ScrambleTest.php @@ -113,5 +113,7 @@ private function assertRoutesAreCacheable($routes) class ScrambleTest_Controller { - public function test() {} + public function test() + { + } } From fb719e43dc7b96a13eb84a7fee8fa10c224b1464 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Fri, 17 May 2024 21:13:05 +0300 Subject: [PATCH 27/38] debug test --- tests/ScrambleTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/ScrambleTest.php b/tests/ScrambleTest.php index ef5897b6..2cbfd3bc 100644 --- a/tests/ScrambleTest.php +++ b/tests/ScrambleTest.php @@ -56,6 +56,8 @@ public function filters_consumer_routes_with_config_file() $doc = $generator(Scramble::getGeneratorConfig('default')); + dump($doc); + $this->assertEquals('http://localhost/api', $doc['servers'][0]['url']); $this->assertEquals(['/a', '/b', '/c'], array_keys($doc['paths'])); } From cde1200ac6e96c4f1ea33624e3a89eb174a2ee7d Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Fri, 17 May 2024 21:36:43 +0300 Subject: [PATCH 28/38] fixed tests failing randomly --- tests/ScrambleTest.php | 2 -- tests/TestCase.php | 7 ++++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/ScrambleTest.php b/tests/ScrambleTest.php index ab23e879..91c69530 100644 --- a/tests/ScrambleTest.php +++ b/tests/ScrambleTest.php @@ -56,8 +56,6 @@ public function filters_consumer_routes_with_config_file() $doc = $generator(Scramble::getGeneratorConfig('default')); - dump($doc); - $this->assertEquals('http://localhost/api', $doc['servers'][0]['url']); $this->assertEquals(['/a', '/b', '/c'], array_keys($doc['paths'])); } diff --git a/tests/TestCase.php b/tests/TestCase.php index c31c2cce..18550bbd 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -22,13 +22,18 @@ protected function setUp(): void Factory::guessFactoryNamesUsing( fn (string $modelName) => 'Dedoc\\Scramble\\Database\\Factories\\'.class_basename($modelName).'Factory' ); + } + + protected function tearDown(): void + { + Context::reset(); Scramble::$defaultRoutesIgnored = false; Scramble::$routeResolver = null; Scramble::$openApiExtender = null; Scramble::$tagResolver = null; - Context::reset(); + parent::tearDown(); } protected function getPackageProviders($app) From f783f01a3713015e3dd1e499416cb9cd43fd5cd3 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sat, 18 May 2024 10:05:51 +0300 Subject: [PATCH 29/38] add support of additional data of anonymous resource collection --- ...nonymousResourceCollectionTypeToSchema.php | 27 +++- .../FlattensMergeValues.php | 115 ++++++++++++++++++ .../JsonResourceTypeToSchema.php | 114 +---------------- .../MergesOpenApiObjects.php | 21 ++++ tests/ResourceCollectionResponseTest.php | 29 ++++- 5 files changed, 187 insertions(+), 119 deletions(-) create mode 100644 src/Support/TypeToSchemaExtensions/FlattensMergeValues.php create mode 100644 src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php diff --git a/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php b/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php index e939c888..d4a23d8d 100644 --- a/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/AnonymousResourceCollectionTypeToSchema.php @@ -7,7 +7,9 @@ use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Types\ArrayType as OpenApiArrayType; use Dedoc\Scramble\Support\Generator\Types\ObjectType as OpenApiObjectType; +use Dedoc\Scramble\Support\Generator\Types\UnknownType; use Dedoc\Scramble\Support\Type\Generic; +use Dedoc\Scramble\Support\Type\KeyedArrayType; use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\TypeWalker; @@ -16,6 +18,9 @@ class AnonymousResourceCollectionTypeToSchema extends TypeToSchemaExtension { + use FlattensMergeValues; + use MergesOpenApiObjects; + public function shouldHandle(Type $type) { return $type instanceof Generic @@ -41,6 +46,11 @@ public function toSchema(Type $type) */ public function toResponse(Type $type) { + $additional = $type->templateTypes[1 /* TAdditional */] ?? new UnknownType(); + if ($additional instanceof KeyedArrayType) { + $additional->items = $this->flattenMergeValues($additional->items); + } + // In case of paginated resource, we want to get pagination response. if ($type->templateTypes[0] instanceof Generic && ! $type->templateTypes[0]->isInstanceOf(JsonResource::class)) { return $this->openApiTransformer->toResponse($type->templateTypes[0]); @@ -51,14 +61,23 @@ public function toResponse(Type $type) } $jsonResourceOpenApiType = $this->openApiTransformer->transform($collectingResourceType); - $responseWrapKey = AnonymousResourceCollection::$wrap; - $openApiType = $responseWrapKey + $shouldWrap = ($wrapKey = AnonymousResourceCollection::$wrap ?? null) !== null + || $additional instanceof KeyedArrayType; + $wrapKey = $wrapKey ?: 'data'; + + $openApiType = $shouldWrap ? (new OpenApiObjectType) - ->addProperty($responseWrapKey, (new OpenApiArrayType)->setItems($jsonResourceOpenApiType)) - ->setRequired([$responseWrapKey]) + ->addProperty($wrapKey, (new OpenApiArrayType)->setItems($jsonResourceOpenApiType)) + ->setRequired([$wrapKey]) : (new OpenApiArrayType)->setItems($jsonResourceOpenApiType); + if ($shouldWrap) { + if ($additional instanceof KeyedArrayType) { + $this->mergeOpenApiObjects($openApiType, $this->openApiTransformer->transform($additional)); + } + } + return Response::make(200) ->description('Array of `'.$this->components->uniqueSchemaName($collectingResourceType->name).'`') ->setContent('application/json', Schema::fromType($openApiType)); diff --git a/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php b/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php new file mode 100644 index 00000000..745d9dfe --- /dev/null +++ b/src/Support/TypeToSchemaExtensions/FlattensMergeValues.php @@ -0,0 +1,115 @@ +flatMap(function (ArrayItemType_ $item) { + if ($item->value instanceof KeyedArrayType) { + $item->value->items = $this->flattenMergeValues($item->value->items); + $item->value->isList = KeyedArrayType::checkIsList($item->value->items); + + return [$item]; + } + + if ( + $item->value instanceof Union + && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(Carbon::class)) + ) { + (new TypeWalker)->replace($item->value, function (Type $t) { + return $t->isInstanceOf(Carbon::class) + ? tap(new StringType, fn ($t) => $t->setAttribute('format', 'date-time')) + : null; + }); + + return [$item]; + } + + if ($item->value->isInstanceOf(JsonResource::class)) { + $resource = $this->getResourceType($item->value); + + if ($resource->isInstanceOf(MissingValue::class)) { + return []; + } + + if ( + $resource instanceof Union + && (new TypeWalker)->first($resource, fn (Type $t) => $t->isInstanceOf(MissingValue::class)) + ) { + $item->isOptional = true; + + return [$item]; + } + } + + if ( + $item->value instanceof Union + && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(MissingValue::class)) + ) { + $newType = array_filter($item->value->types, fn (Type $t) => ! $t->isInstanceOf(MissingValue::class)); + + if (! count($newType)) { + return []; + } + + $item->isOptional = true; + + if (count($newType) === 1) { + $item->value = $newType[0]; + + return $this->flattenMergeValues([$item]); + } + + $item->value = new Union($newType); + + return $this->flattenMergeValues([$item]); + } + + if ( + $item->value instanceof Generic + && $item->value->isInstanceOf(MergeValue::class) + ) { + $arrayToMerge = $item->value->templateTypes[1]; + + // Second generic argument of the `MergeValue` class must be a keyed array. + // Otherwise, we ignore it from the resulting array. + if (! $arrayToMerge instanceof KeyedArrayType) { + return []; + } + + $arrayToMergeItems = $this->flattenMergeValues($arrayToMerge->items); + + $mergingArrayValuesShouldBeRequired = $item->value->templateTypes[0] instanceof LiteralBooleanType + && $item->value->templateTypes[0]->value === true; + + if (! $mergingArrayValuesShouldBeRequired || $item->isOptional) { + foreach ($arrayToMergeItems as $mergingItem) { + $mergingItem->isOptional = true; + } + } + + return $arrayToMergeItems; + } + + return [$item]; + }) + ->values() + ->all(); + } +} diff --git a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php index cf75ae59..efb02269 100644 --- a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php @@ -28,6 +28,9 @@ class JsonResourceTypeToSchema extends TypeToSchemaExtension { + use FlattensMergeValues; + use MergesOpenApiObjects; + public function shouldHandle(Type $type) { return $type instanceof ObjectType @@ -69,102 +72,6 @@ public function toSchema(Type $type) return $this->openApiTransformer->transform($array); } - private function flattenMergeValues(array $items) - { - return collect($items) - ->flatMap(function (ArrayItemType_ $item) { - if ($item->value instanceof KeyedArrayType) { - $item->value->items = $this->flattenMergeValues($item->value->items); - $item->value->isList = KeyedArrayType::checkIsList($item->value->items); - - return [$item]; - } - - if ( - $item->value instanceof Union - && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(Carbon::class)) - ) { - (new TypeWalker)->replace($item->value, function (Type $t) { - return $t->isInstanceOf(Carbon::class) - ? tap(new StringType, fn ($t) => $t->setAttribute('format', 'date-time')) - : null; - }); - - return [$item]; - } - - if ($item->value->isInstanceOf(JsonResource::class)) { - $resource = $this->getResourceType($item->value); - - if ($resource->isInstanceOf(MissingValue::class)) { - return []; - } - - if ( - $resource instanceof Union - && (new TypeWalker)->first($resource, fn (Type $t) => $t->isInstanceOf(MissingValue::class)) - ) { - $item->isOptional = true; - - return [$item]; - } - } - - if ( - $item->value instanceof Union - && (new TypeWalker)->first($item->value, fn (Type $t) => $t->isInstanceOf(MissingValue::class)) - ) { - $newType = array_filter($item->value->types, fn (Type $t) => ! $t->isInstanceOf(MissingValue::class)); - - if (! count($newType)) { - return []; - } - - $item->isOptional = true; - - if (count($newType) === 1) { - $item->value = $newType[0]; - - return $this->flattenMergeValues([$item]); - } - - $item->value = new Union($newType); - - return $this->flattenMergeValues([$item]); - } - - if ( - $item->value instanceof Generic - && $item->value->isInstanceOf(MergeValue::class) - ) { - $arrayToMerge = $item->value->templateTypes[1]; - - // Second generic argument of the `MergeValue` class must be a keyed array. - // Otherwise, we ignore it from the resulting array. - if (! $arrayToMerge instanceof KeyedArrayType) { - return []; - } - - $arrayToMergeItems = $this->flattenMergeValues($arrayToMerge->items); - - $mergingArrayValuesShouldBeRequired = $item->value->templateTypes[0] instanceof LiteralBooleanType - && $item->value->templateTypes[0]->value === true; - - if (! $mergingArrayValuesShouldBeRequired || $item->isOptional) { - foreach ($arrayToMergeItems as $mergingItem) { - $mergingItem->isOptional = true; - } - } - - return $arrayToMergeItems; - } - - return [$item]; - }) - ->values() - ->all(); - } - /** * @param Generic $type */ @@ -215,7 +122,7 @@ public function reference(ObjectType $type) return new Reference('schemas', $type->name, $this->components); /* - * @todo: Allow (enforce) user to explicitly pass short and unique names for the reference. + * @todo: Allow (enforce) user to explicitly pass short and unique names for the reference and avoid passing components. * Otherwise, only class names are correctly handled for now. */ return Reference::in('schemas') @@ -223,19 +130,6 @@ public function reference(ObjectType $type) ->uniqueName($type->name); } - private function mergeOpenApiObjects(OpenApiTypes\ObjectType $into, OpenApiTypes\Type $what) - { - if (! $what instanceof OpenApiTypes\ObjectType) { - return; - } - - foreach ($what->properties as $name => $property) { - $into->addProperty($name, $property); - } - - $into->addRequired(array_keys($what->properties)); - } - private function getResourceType(Type $type): Type { if (! $type instanceof Generic) { diff --git a/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php b/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php new file mode 100644 index 00000000..184b7f37 --- /dev/null +++ b/src/Support/TypeToSchemaExtensions/MergesOpenApiObjects.php @@ -0,0 +1,21 @@ +properties as $name => $property) { + $into->addProperty($name, $property); + } + + $into->addRequired(array_keys($what->properties)); + } +} diff --git a/tests/ResourceCollectionResponseTest.php b/tests/ResourceCollectionResponseTest.php index d260397b..c52698e8 100644 --- a/tests/ResourceCollectionResponseTest.php +++ b/tests/ResourceCollectionResponseTest.php @@ -112,14 +112,12 @@ class UserCollection_Four extends \Illuminate\Http\Resources\Json\ResourceCollec } it('attaches additional data to the response documentation', function () { - RouteFacade::get('api/test', [ResourceCollectionResponseTest_Controller::class, 'index']); - - Scramble::routes(fn (Route $r) => $r->uri === 'api/test'); - $openApiDocument = app()->make(\Dedoc\Scramble\Generator::class)(); + $openApiDocument = generateForRoute(function () { + return RouteFacade::get('api/test', [ResourceCollectionResponseTest_Controller::class, 'index']); + }); assertMatchesSnapshot($openApiDocument); }); - class ResourceCollectionResponseTest_Controller { public function index(Request $request) @@ -131,6 +129,27 @@ public function index(Request $request) } } +it('attaches additional data to the response documentation for annotation', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::get('api/test', [AnnotationResourceCollectionResponseTest_Controller::class, 'index']); + }); + + expect($props = $openApiDocument['paths']['/test']['get']['responses'][200]['content']['application/json']['schema']['properties']) + ->toHaveKeys(['data', 'something']) + ->and($props['something']['properties']) + ->toBe(['foo' => ['type' => 'string', 'example' => 'bar']]); +}); +class AnnotationResourceCollectionResponseTest_Controller +{ + public function index(Request $request) + { + return UserResource::collection(collect()) + ->additional([ + 'something' => ['foo' => 'bar'], + ]); + } +} + class UserResource extends \Illuminate\Http\Resources\Json\JsonResource { public function toArray($request) From b34286c2395cad5c3fd1fa2cfd4876d9422e957b Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sat, 18 May 2024 07:06:17 +0000 Subject: [PATCH 30/38] Fix styling --- .../TypeToSchemaExtensions/JsonResourceTypeToSchema.php | 9 --------- tests/ResourceCollectionResponseTest.php | 2 -- 2 files changed, 11 deletions(-) diff --git a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php index efb02269..f7de50ab 100644 --- a/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php +++ b/src/Support/TypeToSchemaExtensions/JsonResourceTypeToSchema.php @@ -2,29 +2,20 @@ namespace Dedoc\Scramble\Support\TypeToSchemaExtensions; -use Carbon\Carbon; use Dedoc\Scramble\Extensions\TypeToSchemaExtension; use Dedoc\Scramble\Support\Generator\Reference; use Dedoc\Scramble\Support\Generator\Response; use Dedoc\Scramble\Support\Generator\Schema; -use Dedoc\Scramble\Support\Generator\Types as OpenApiTypes; use Dedoc\Scramble\Support\Generator\Types\UnknownType; use Dedoc\Scramble\Support\InferExtensions\ResourceCollectionTypeInfer; -use Dedoc\Scramble\Support\Type\ArrayItemType_; use Dedoc\Scramble\Support\Type\ArrayType; use Dedoc\Scramble\Support\Type\Generic; use Dedoc\Scramble\Support\Type\KeyedArrayType; -use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType; use Dedoc\Scramble\Support\Type\ObjectType; -use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\Type; -use Dedoc\Scramble\Support\Type\TypeWalker; -use Dedoc\Scramble\Support\Type\Union; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\ResourceCollection; -use Illuminate\Http\Resources\MergeValue; -use Illuminate\Http\Resources\MissingValue; class JsonResourceTypeToSchema extends TypeToSchemaExtension { diff --git a/tests/ResourceCollectionResponseTest.php b/tests/ResourceCollectionResponseTest.php index c52698e8..082b84af 100644 --- a/tests/ResourceCollectionResponseTest.php +++ b/tests/ResourceCollectionResponseTest.php @@ -1,12 +1,10 @@ Date: Sat, 18 May 2024 10:39:05 +0300 Subject: [PATCH 31/38] deeper binary lookup in parameters when determining default request media type --- .../RequestBodyExtension.php | 7 +++---- .../RequestBodyExtensionTest.php | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 3d847883..1ec5852a 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -105,11 +105,10 @@ protected function getMediaType(Operation $operation, RouteInfo $routeInfo, arra protected function hasBinary($bodyParams): bool { return collect($bodyParams)->contains(function (Parameter $parameter) { - if (property_exists($parameter?->schema?->type, 'format')) { - return $parameter->schema->type->format === 'binary'; - } + // @todo: Use OpenApi document tree walker when ready + $parameterString = json_encode($parameter->toArray()); - return false; + return Str::contains($parameterString, '"format":"binary"'); }); } diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index 3262dea1..b1648164 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -81,6 +81,25 @@ public function index(Illuminate\Http\Request $request) } } +it('automatically infers multipart/form-data as request media type when some of body params is binary on a deeper layers', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::post('api/test', [RequestBodyExtensionTest__automaticall_infers_form_data_from_deeper::class, 'index']); + }); + + expect($openApiDocument['paths']['/test']['post']['requestBody']['content']) + ->toHaveKey('multipart/form-data') + ->toHaveLength(1); +}); +class RequestBodyExtensionTest__automaticall_infers_form_data_from_deeper +{ + public function index(Illuminate\Http\Request $request) + { + $request->validate([ + 'foo.*' => 'file' + ]); + } +} + it('extracts parameters, their defaults, and descriptions from calling request parameters retrieving methods with scalar types', function () { $openApiDocument = generateForRoute(function () { return RouteFacade::post('api/test', [RequestBodyExtensionTest__extracts_parameters_from_retrieving_methods_with_scalar_types::class, 'index']); From 9c296cd6a80c7b242035a6c4c66098d10ca653be Mon Sep 17 00:00:00 2001 From: romalytvynenko Date: Sat, 18 May 2024 07:39:30 +0000 Subject: [PATCH 32/38] Fix styling --- tests/Support/OperationExtensions/RequestBodyExtensionTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php index b1648164..56a122f8 100644 --- a/tests/Support/OperationExtensions/RequestBodyExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestBodyExtensionTest.php @@ -95,7 +95,7 @@ class RequestBodyExtensionTest__automaticall_infers_form_data_from_deeper public function index(Illuminate\Http\Request $request) { $request->validate([ - 'foo.*' => 'file' + 'foo.*' => 'file', ]); } } From da3503537d5be4f4b99653737176c2ff3108f6b4 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sat, 18 May 2024 12:12:36 +0300 Subject: [PATCH 33/38] using methods reall class reflection when analyzing parent class calls --- src/Infer/Reflector/MethodReflector.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Infer/Reflector/MethodReflector.php b/src/Infer/Reflector/MethodReflector.php index eff462ae..f4744097 100644 --- a/src/Infer/Reflector/MethodReflector.php +++ b/src/Infer/Reflector/MethodReflector.php @@ -95,6 +95,6 @@ public function beforeTraverse(array $nodes): ?array public function getClassReflector(): ClassReflector { - return ClassReflector::make($this->className); + return ClassReflector::make($this->getReflection()->class); } } From 864871ba7c72ece6bc73e500912a082070afbe9b Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 19 May 2024 10:50:28 +0300 Subject: [PATCH 34/38] improved errors documentation by avoiding documenting errors when authorize or rules methods are not defined on custom form request class --- src/Infer/Definition/ClassDefinition.php | 5 ++++ .../ErrorResponsesExtension.php | 28 +++++++++++-------- tests/ErrorsResponsesTest.php | 18 ++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/Infer/Definition/ClassDefinition.php b/src/Infer/Definition/ClassDefinition.php index 9affd5be..a86d2e4f 100644 --- a/src/Infer/Definition/ClassDefinition.php +++ b/src/Infer/Definition/ClassDefinition.php @@ -43,6 +43,11 @@ public function isChildOf(string $className) return $this->isInstanceOf($className) && $this->name !== $className; } + public function hasMethodDefinition(string $name): bool + { + return array_key_exists($name, $this->methods); + } + public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = []) { if (! array_key_exists($name, $this->methods)) { diff --git a/src/Support/OperationExtensions/ErrorResponsesExtension.php b/src/Support/OperationExtensions/ErrorResponsesExtension.php index 4610e29e..de7cf3bf 100644 --- a/src/Support/OperationExtensions/ErrorResponsesExtension.php +++ b/src/Support/OperationExtensions/ErrorResponsesExtension.php @@ -82,22 +82,26 @@ private function attachCustomRequestExceptions(FunctionType $methodType) return; } - $methodType->exceptions = [ - ...$methodType->exceptions, - new ObjectType(ValidationException::class), - ]; + $formRequest = $this->infer->analyzeClass($formRequest->name); - $formRequest = $this->infer->analyzeClass($formRequest->name, ['authorize']); - - $authorizeReturnType = $formRequest->getMethodCallType('authorize'); - if ( - (! $authorizeReturnType instanceof LiteralBooleanType) - || $authorizeReturnType->value !== true - ) { + if ($formRequest->hasMethodDefinition('rules')) { $methodType->exceptions = [ ...$methodType->exceptions, - new ObjectType(AuthorizationException::class), + new ObjectType(ValidationException::class), ]; } + + if ($formRequest->hasMethodDefinition('authorize')) { + $authorizeReturnType = $formRequest->getMethodCallType('authorize'); + if ( + (! $authorizeReturnType instanceof LiteralBooleanType) + || $authorizeReturnType->value !== true + ) { + $methodType->exceptions = [ + ...$methodType->exceptions, + new ObjectType(AuthorizationException::class), + ]; + } + } } } diff --git a/tests/ErrorsResponsesTest.php b/tests/ErrorsResponsesTest.php index ab048f85..7160bc98 100644 --- a/tests/ErrorsResponsesTest.php +++ b/tests/ErrorsResponsesTest.php @@ -37,6 +37,16 @@ assertMatchesSnapshot($openApiDocument); }); +it('doesnt add errors with custom request when errors producing methods are not defined', function () { + $openApiDocument = generateForRoute(function () { + return RouteFacade::get('api/test', [ErrorsResponsesTest_Controller::class, 'doesnt_add_errors_with_custom_request_when_errors_producing_methods_not_defined']); + }); + + expect($openApiDocument['paths']['/test']['get']['responses']) + ->toHaveKeys([200]) + ->toHaveCount(1); +}); + it('adds auth error response', function () { RouteFacade::get('api/test', [ErrorsResponsesTest_Controller::class, 'adds_auth_error_response']); @@ -83,6 +93,10 @@ public function adds_errors_with_custom_request(ErrorsResponsesTest_Controller_C { } + public function doesnt_add_errors_with_custom_request_when_errors_producing_methods_not_defined(ErrorsResponsesTest_Controller_CustomRequestWithoutErrorCreatingMethods $request) + { + } + public function adds_auth_error_response(Illuminate\Http\Request $request) { $this->authorize('read'); @@ -116,3 +130,7 @@ public function rules() return ['foo' => 'required']; } } + +class ErrorsResponsesTest_Controller_CustomRequestWithoutErrorCreatingMethods extends \Illuminate\Foundation\Http\FormRequest +{ +} From a48a8da3f6e2a6fcd12c6c2faeaf3287a50dc973 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 19 May 2024 11:03:48 +0300 Subject: [PATCH 35/38] documenting validation error when after method defined on a form request --- src/Support/OperationExtensions/ErrorResponsesExtension.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Support/OperationExtensions/ErrorResponsesExtension.php b/src/Support/OperationExtensions/ErrorResponsesExtension.php index de7cf3bf..92644f54 100644 --- a/src/Support/OperationExtensions/ErrorResponsesExtension.php +++ b/src/Support/OperationExtensions/ErrorResponsesExtension.php @@ -84,7 +84,10 @@ private function attachCustomRequestExceptions(FunctionType $methodType) $formRequest = $this->infer->analyzeClass($formRequest->name); - if ($formRequest->hasMethodDefinition('rules')) { + if ( + $formRequest->hasMethodDefinition('rules') + || $formRequest->hasMethodDefinition('after') + ) { $methodType->exceptions = [ ...$methodType->exceptions, new ObjectType(ValidationException::class), From b9c6ecaf21e9326e629b0cfa0d15b23e1beead7c Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 19 May 2024 12:30:02 +0300 Subject: [PATCH 36/38] fixed property fetching inference logic, fixed relations retrieval for child models --- src/Infer/Services/ReferenceTypeResolver.php | 10 +++++----- src/Support/ResponseExtractor/ModelInfo.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Infer/Services/ReferenceTypeResolver.php b/src/Infer/Services/ReferenceTypeResolver.php index 6d9edc02..30df1eef 100644 --- a/src/Infer/Services/ReferenceTypeResolver.php +++ b/src/Infer/Services/ReferenceTypeResolver.php @@ -270,17 +270,17 @@ private function resolveNewCallReferenceType(Scope $scope, NewCallReferenceType private function resolvePropertyFetchReferenceType(Scope $scope, PropertyFetchReferenceType $type) { + $objectType = $this->resolve($scope, $type->object); + if ( - ($type->object instanceof ObjectType) - && ! array_key_exists($type->object->name, $this->index->classesDefinitions) - && ! $this->resolveUnknownClassResolver($type->object->name) + ($objectType instanceof ObjectType) + && ! array_key_exists($objectType->name, $this->index->classesDefinitions) + && ! $this->resolveUnknownClassResolver($objectType->name) ) { // Class is not indexed, and we simply cannot get an info from it. return $type; } - $objectType = $this->resolve($scope, $type->object); - if ( $objectType instanceof AbstractReferenceType || $objectType instanceof TemplateType diff --git a/src/Support/ResponseExtractor/ModelInfo.php b/src/Support/ResponseExtractor/ModelInfo.php index 9faef86b..152ccc7d 100644 --- a/src/Support/ResponseExtractor/ModelInfo.php +++ b/src/Support/ResponseExtractor/ModelInfo.php @@ -255,7 +255,7 @@ protected function getRelations($model) ->reject( fn (ReflectionMethod $method) => $method->isStatic() || $method->isAbstract() - || $method->getDeclaringClass()->getName() !== get_class($model) + || $method->getDeclaringClass()->getName() === Model::class, ) ->filter(function (ReflectionMethod $method) { $file = new SplFileObject($method->getFileName()); From bcafb37aee891575cbdd9590d210a81c785adb45 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Sun, 19 May 2024 14:32:39 +0300 Subject: [PATCH 37/38] fixed use in comment in file beginnig breaking parsing --- src/Infer/Reflector/ClassReflector.php | 3 +++ src/Infer/Services/FileParser.php | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Infer/Reflector/ClassReflector.php b/src/Infer/Reflector/ClassReflector.php index 85f824c5..e023c57d 100644 --- a/src/Infer/Reflector/ClassReflector.php +++ b/src/Infer/Reflector/ClassReflector.php @@ -56,6 +56,9 @@ public function getNameContext(): NameContext $code = Str::before($content, $firstMatchedClassLikeString); + // Removes all comments. + $code = preg_replace('/\/\*(?:[^*]|\*+[^*\/])*\*+\/|(?cache[md5($content).'23'] ??= new FileParserResult( + return $this->cache[md5($content)] ??= new FileParserResult( $statements = Arr::wrap($this->parser->parse($content)), new FileNameResolver(new NameContext(new Throwing())) ); From c4ebd96b866f6d356b4c90913a8ead8da6050a08 Mon Sep 17 00:00:00 2001 From: Roman Lytvynenko Date: Mon, 20 May 2024 15:45:23 +0300 Subject: [PATCH 38/38] using app helper so TypeTransformer always gets the latest container --- src/ScrambleServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ScrambleServiceProvider.php b/src/ScrambleServiceProvider.php index e99fb81a..dc8a73d1 100644 --- a/src/ScrambleServiceProvider.php +++ b/src/ScrambleServiceProvider.php @@ -130,7 +130,7 @@ public function configurePackage(Package $package): void )); return new TypeTransformer( - $this->app->make(Infer::class), + app()->make(Infer::class), new Components, array_merge($typesToSchemaExtensions, [ EnumToSchema::class,