diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index 217c72c2..b681320b 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -15,6 +15,7 @@ jobs: os: [ubuntu-latest] php: [8.1, 8.2] laravel: [11.*, 10.*] + phpdoc-parser: [1.*, 2.*] stability: [prefer-lowest, prefer-stable] include: - laravel: 10.* @@ -25,7 +26,7 @@ jobs: - laravel: 11.* php: 8.1 - name: P${{ matrix.php }} - L${{ matrix.laravel }} - ${{ matrix.stability }} - ${{ matrix.os }} + name: P${{ matrix.php }} - L${{ matrix.laravel }} - PHPDoc-Parser${{ matrix.phpdoc-parser }} - ${{ matrix.stability }} - ${{ matrix.os }} steps: - name: Checkout code @@ -46,7 +47,7 @@ jobs: - name: Install dependencies run: | composer config --no-plugins allow-plugins.pestphp/pest-plugin true - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.phpdoc-parser }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - name: Execute tests diff --git a/composer.json b/composer.json index d7f1e9fe..f1b57557 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "illuminate/contracts": "^10.0|^11.0", "myclabs/deep-copy": "^1.12", "nikic/php-parser": "^5.0", - "phpstan/phpdoc-parser": "^1.33.0", + "phpstan/phpdoc-parser": "^1.0|^2.0", "spatie/laravel-package-tools": "^1.9.2" }, "require-dev": { diff --git a/dictionaries/classMap.php b/dictionaries/classMap.php index 67e2984f..d38690ee 100644 --- a/dictionaries/classMap.php +++ b/dictionaries/classMap.php @@ -1,4 +1,5 @@ isStatic(), ); } diff --git a/src/Infer/Definition/ClassDefinition.php b/src/Infer/Definition/ClassDefinition.php index 4940d18a..eeb24a67 100644 --- a/src/Infer/Definition/ClassDefinition.php +++ b/src/Infer/Definition/ClassDefinition.php @@ -5,6 +5,7 @@ use Dedoc\Scramble\Infer\Analyzer\MethodAnalyzer; use Dedoc\Scramble\Infer\Reflector\ClassReflector; use Dedoc\Scramble\Infer\Scope\GlobalScope; +use Dedoc\Scramble\Infer\Scope\Index; use Dedoc\Scramble\Infer\Scope\NodeTypesResolver; use Dedoc\Scramble\Infer\Scope\Scope; use Dedoc\Scramble\Infer\Scope\ScopeContext; @@ -48,6 +49,35 @@ public function hasMethodDefinition(string $name): bool return array_key_exists($name, $this->methods); } + public function getMethodDefinitionWithoutAnalysis(string $name) + { + if (! array_key_exists($name, $this->methods)) { + return null; + } + + return $this->methods[$name]; + } + + public function getMethodDefiningClassName(string $name, Index $index) + { + $lastLookedUpClassName = $this->name; + while ($lastLookedUpClassDefinition = $index->getClassDefinition($lastLookedUpClassName)) { + if ($methodDefinition = $lastLookedUpClassDefinition->getMethodDefinitionWithoutAnalysis($name)) { + return $methodDefinition->definingClassName; + } + + if ($lastLookedUpClassDefinition->parentFqn) { + $lastLookedUpClassName = $lastLookedUpClassDefinition->parentFqn; + + continue; + } + + break; + } + + return $lastLookedUpClassName; + } + public function getMethodDefinition(string $name, Scope $scope = new GlobalScope, array $indexBuilders = []) { if (! array_key_exists($name, $this->methods)) { diff --git a/src/Infer/Definition/FunctionLikeDefinition.php b/src/Infer/Definition/FunctionLikeDefinition.php index d7475bc4..07b0e052 100644 --- a/src/Infer/Definition/FunctionLikeDefinition.php +++ b/src/Infer/Definition/FunctionLikeDefinition.php @@ -17,6 +17,7 @@ public function __construct( public array $sideEffects = [], public array $argumentsDefaults = [], public ?string $definingClassName = null, + public bool $isStatic = false, ) {} public function isFullyAnalyzed(): bool diff --git a/src/Infer/Extensions/Event/MethodCallEvent.php b/src/Infer/Extensions/Event/MethodCallEvent.php index 4d4c3659..68c7b9d6 100644 --- a/src/Infer/Extensions/Event/MethodCallEvent.php +++ b/src/Infer/Extensions/Event/MethodCallEvent.php @@ -15,6 +15,7 @@ public function __construct( public readonly string $name, public readonly Scope $scope, public readonly array $arguments, + public readonly ?string $methodDefiningClassName, ) {} public function getDefinition() diff --git a/src/Infer/Extensions/ExtensionsBroker.php b/src/Infer/Extensions/ExtensionsBroker.php index f3577174..46a8f9c6 100644 --- a/src/Infer/Extensions/ExtensionsBroker.php +++ b/src/Infer/Extensions/ExtensionsBroker.php @@ -40,6 +40,23 @@ public function getMethodReturnType($event) return null; } + public function getMethodCallExceptions($event) + { + $extensions = array_filter($this->extensions, function ($e) use ($event) { + return $e instanceof MethodCallExceptionsExtension + && $e->shouldHandle($event->getInstance()); + }); + + $exceptions = []; + foreach ($extensions as $extension) { + if ($extensionExceptions = $extension->getMethodCallExceptions($event)) { + $exceptions = array_merge($exceptions, $extensionExceptions); + } + } + + return $exceptions; + } + public function getStaticMethodReturnType($event) { $extensions = array_filter($this->extensions, function ($e) use ($event) { diff --git a/src/Infer/Extensions/MethodCallExceptionsExtension.php b/src/Infer/Extensions/MethodCallExceptionsExtension.php new file mode 100644 index 00000000..ee60bfdf --- /dev/null +++ b/src/Infer/Extensions/MethodCallExceptionsExtension.php @@ -0,0 +1,17 @@ + + */ + public function getMethodCallExceptions(MethodCallEvent $event): array; +} diff --git a/src/Infer/Handler/FunctionLikeHandler.php b/src/Infer/Handler/FunctionLikeHandler.php index e66da521..6b9eba90 100644 --- a/src/Infer/Handler/FunctionLikeHandler.php +++ b/src/Infer/Handler/FunctionLikeHandler.php @@ -49,6 +49,7 @@ public function enter(FunctionLike $node, Scope $scope) $scope->context->setFunctionDefinition($fnDefinition = new FunctionLikeDefinition( type: $fnType = new FunctionType($node->name->name ?? 'anonymous'), sideEffects: [], + isStatic: $node instanceof Node\Stmt\ClassMethod ? $node->isStatic() : false, )); $fnDefinition->isFullyAnalyzed = true; diff --git a/src/Infer/Reflector/MethodReflector.php b/src/Infer/Reflector/MethodReflector.php index 5a69bcc3..b469094d 100644 --- a/src/Infer/Reflector/MethodReflector.php +++ b/src/Infer/Reflector/MethodReflector.php @@ -48,7 +48,10 @@ public function getMethodCode(): string public function getReflection(): ReflectionMethod { - return new ReflectionMethod($this->className, $this->name); + /** + * \ReflectionMethod could've been used here, but for `\Closure::__invoke` it fails when constructed manually + */ + return (new \ReflectionClass($this->className))->getMethod($this->name); } /** diff --git a/src/Infer/Scope/Scope.php b/src/Infer/Scope/Scope.php index 34804830..87ca74ae 100644 --- a/src/Infer/Scope/Scope.php +++ b/src/Infer/Scope/Scope.php @@ -3,6 +3,8 @@ namespace Dedoc\Scramble\Infer\Scope; use Dedoc\Scramble\Infer\Definition\ClassDefinition; +use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent; +use Dedoc\Scramble\Infer\Extensions\ExtensionsBroker; use Dedoc\Scramble\Infer\Services\FileNameResolver; use Dedoc\Scramble\Infer\SimpleTypeGetters\BooleanNotTypeGetter; use Dedoc\Scramble\Infer\SimpleTypeGetters\CastTypeGetter; @@ -64,11 +66,7 @@ public function getType(Node $node): Type } if ($node instanceof Node\Expr\Match_) { - return Union::wrap( - collect($node->arms) - ->map(fn (Node\MatchArm $arm) => $this->getType($arm->body)) - ->toArray() - ); + return Union::wrap(array_map(fn (Node\MatchArm $arm) => $this->getType($arm->body), $node->arms)); } if ($node instanceof Node\Expr\ClassConstFetch) { @@ -115,7 +113,17 @@ public function getType(Node $node): Type } $calleeType = $this->getType($node->var); - if ($calleeType instanceof TemplateType) { + + $event = $calleeType instanceof ObjectType + ? new MethodCallEvent($calleeType, $node->name->name, $this, $this->getArgsTypes($node->args), $calleeType->name) + : null; + + $exceptions = $event ? app(ExtensionsBroker::class)->getMethodCallExceptions($event) : []; + + if ( + $calleeType instanceof TemplateType + && ! $exceptions + ) { // @todo // if ($calleeType->is instanceof ObjectType) { // $calleeType = $calleeType->is; @@ -129,12 +137,17 @@ public function getType(Node $node): Type * When inside a constructor, we want to add a side effect to the constructor definition, so we can track * how the properties are being set. */ - if ( - $this->functionDefinition()?->type->name === '__construct' - ) { + if ($this->functionDefinition()?->type->name === '__construct') { $this->functionDefinition()->sideEffects[] = $referenceType; } + if ($this->functionDefinition()) { + $this->functionDefinition()->type->exceptions = array_merge( + $this->functionDefinition()->type->exceptions, + $exceptions, + ); + } + return $this->setType($node, $referenceType); } diff --git a/src/Infer/Services/ConstFetchTypeGetter.php b/src/Infer/Services/ConstFetchTypeGetter.php index e14d6169..013dece9 100644 --- a/src/Infer/Services/ConstFetchTypeGetter.php +++ b/src/Infer/Services/ConstFetchTypeGetter.php @@ -3,6 +3,7 @@ namespace Dedoc\Scramble\Infer\Services; use Dedoc\Scramble\Infer\Scope\Scope; +use Dedoc\Scramble\Support\Type\EnumCaseType; use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; use Dedoc\Scramble\Support\Type\TypeHelper; use Dedoc\Scramble\Support\Type\UnknownType; @@ -19,6 +20,10 @@ public function __invoke(Scope $scope, string $className, string $constName) $constantReflection = new \ReflectionClassConstant($className, $constName); $constantValue = $constantReflection->getValue(); + if ($constantReflection->isEnumCase()) { + return new EnumCaseType($className, $constName); + } + $type = TypeHelper::createTypeFromValue($constantValue); if ($type) { diff --git a/src/Infer/Services/ReferenceTypeResolver.php b/src/Infer/Services/ReferenceTypeResolver.php index 5c895155..00d1560d 100644 --- a/src/Infer/Services/ReferenceTypeResolver.php +++ b/src/Infer/Services/ReferenceTypeResolver.php @@ -265,11 +265,14 @@ private function resolveMethodCallReferenceType(Scope $scope, MethodCallReferenc : $calleeType; if ($unwrappedType instanceof ObjectType) { + $classDefinition = $this->index->getClassDefinition($unwrappedType->name); + $event = new MethodCallEvent( instance: $unwrappedType, name: $type->methodName, scope: $scope, arguments: $type->arguments, + methodDefiningClassName: $classDefinition ? $classDefinition->getMethodDefiningClassName($type->methodName, $scope->index) : $unwrappedType->name, ); } @@ -314,12 +317,16 @@ private function resolveStaticMethodCallReferenceType(Scope $scope, StaticMethod $type->arguments, ); + $calleeName = $type->callee; $contextualClassName = $this->resolveClassName($scope, $type->callee); if (! $contextualClassName) { return new UnknownType; } $type->callee = $contextualClassName; + $isStaticCall = ! in_array($calleeName, StaticReference::KEYWORDS) + || (in_array($calleeName, StaticReference::KEYWORDS) && $scope->context->functionDefinition?->isStatic); + // Assuming callee here can be only string of known name. Reality is more complex than // that, but it is fine for now. @@ -329,7 +336,7 @@ private function resolveStaticMethodCallReferenceType(Scope $scope, StaticMethod $this->resolveUnknownClass($type->callee); // Attempting extensions broker before potentially giving up on type inference - if ($returnType = Context::getInstance()->extensionsBroker->getStaticMethodReturnType(new StaticMethodCallEvent( + if ($isStaticCall && $returnType = Context::getInstance()->extensionsBroker->getStaticMethodReturnType(new StaticMethodCallEvent( callee: $type->callee, name: $type->methodName, scope: $scope, @@ -338,6 +345,25 @@ private function resolveStaticMethodCallReferenceType(Scope $scope, StaticMethod return $returnType; } + // Attempting extensions broker before potentially giving up on type inference + if (! $isStaticCall && $scope->context->classDefinition) { + $definingMethodName = ($definingClass = $scope->index->getClassDefinition($contextualClassName)) + ? $definingClass->getMethodDefiningClassName($type->methodName, $scope->index) + : $contextualClassName; + + $returnType = Context::getInstance()->extensionsBroker->getMethodReturnType($e = new MethodCallEvent( + instance: $i = new ObjectType($scope->context->classDefinition->name), + name: $type->methodName, + scope: $scope, + arguments: $type->arguments, + methodDefiningClassName: $definingMethodName, + )); + + if ($returnType) { + return $returnType; + } + } + if (! array_key_exists($type->callee, $this->index->classesDefinitions)) { return new UnknownType; } @@ -372,7 +398,10 @@ private function resolveUnknownClass(string $className): ?ClassDefinition private function resolveCallableCallReferenceType(Scope $scope, CallableCallReferenceType $type) { - if ($type->callee instanceof CallableStringType) { + $callee = $this->resolve($scope, $type->callee); + $callee = $callee instanceof TemplateType ? $callee->is : $callee; + + if ($callee instanceof CallableStringType) { $analyzedType = clone $type; $analyzedType->arguments = array_map( @@ -382,7 +411,7 @@ private function resolveCallableCallReferenceType(Scope $scope, CallableCallRefe ); $returnType = Context::getInstance()->extensionsBroker->getFunctionReturnType(new FunctionCallEvent( - name: $analyzedType->callee->name, + name: $callee->name, scope: $scope, arguments: $analyzedType->arguments, )); @@ -392,7 +421,14 @@ private function resolveCallableCallReferenceType(Scope $scope, CallableCallRefe } } - $calleeType = $type->callee instanceof CallableStringType + if ($callee instanceof ObjectType) { + return $this->resolve( + $scope, + new MethodCallReferenceType($callee, '__invoke', $type->arguments), + ); + } + + $calleeType = $callee instanceof CallableStringType ? $this->index->getFunctionDefinition($type->callee->name) : $this->resolve($scope, $type->callee); @@ -737,6 +773,7 @@ private function getMethodCallsSideEffectIntroducedTypesInConstructor(Generic $t name: $se->methodName, scope: $scope, arguments: $se->arguments, + methodDefiningClassName: $type->name, )); } diff --git a/src/PhpDoc/AbstractPhpDocTypeVisitor.php b/src/PhpDoc/AbstractPhpDocTypeVisitor.php index ec48b839..172a9ef0 100644 --- a/src/PhpDoc/AbstractPhpDocTypeVisitor.php +++ b/src/PhpDoc/AbstractPhpDocTypeVisitor.php @@ -2,11 +2,12 @@ namespace Dedoc\Scramble\PhpDoc; +use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\Type\TypeNode; class AbstractPhpDocTypeVisitor implements PhpDocTypeVisitor { - public function enter(TypeNode $type): void {} + public function enter(TypeNode|Node $type): void {} - public function leave(TypeNode $type): void {} + public function leave(TypeNode|Node $type): void {} } diff --git a/src/PhpDoc/ResolveFqnPhpDocTypeVisitor.php b/src/PhpDoc/ResolveFqnPhpDocTypeVisitor.php index c481794e..efd67368 100644 --- a/src/PhpDoc/ResolveFqnPhpDocTypeVisitor.php +++ b/src/PhpDoc/ResolveFqnPhpDocTypeVisitor.php @@ -3,6 +3,7 @@ namespace Dedoc\Scramble\PhpDoc; use Dedoc\Scramble\Infer\Services\FileNameResolver; +use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; @@ -15,7 +16,7 @@ public function __construct(FileNameResolver $nameResolver) $this->nameResolver = $nameResolver; } - public function enter(TypeNode $type): void + public function enter(TypeNode|Node $type): void { if ($type instanceof IdentifierTypeNode) { $type->name = ($this->nameResolver)($type->name); diff --git a/src/Scramble.php b/src/Scramble.php index c7a1093a..0c77e68c 100755 --- a/src/Scramble.php +++ b/src/Scramble.php @@ -47,6 +47,14 @@ class Scramble */ public static array $extensions = []; + /** + * Whether to throw an exception during docs generation. When `false`, + * documentation will be generated and issues added to the endpoint description + * that failed generation. When `true`, the exception will be thrown and docs + * generation will fail. + */ + public static bool $throwOnError = false; + /** * Disables registration of default API documentation routes. */ @@ -185,4 +193,14 @@ public static function getGeneratorConfig(string $api) return Scramble::$apis[$api]; } + + public static function throwOnError(bool $throw = true): void + { + static::$throwOnError = $throw; + } + + public function shouldThrowOnError(): bool + { + return static::$throwOnError; + } } diff --git a/src/Support/Generator/TypeTransformer.php b/src/Support/Generator/TypeTransformer.php index d5cdcaaf..f0a1bab7 100644 --- a/src/Support/Generator/TypeTransformer.php +++ b/src/Support/Generator/TypeTransformer.php @@ -23,6 +23,7 @@ use Dedoc\Scramble\Support\Type\TemplateType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\Union; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; @@ -221,46 +222,44 @@ public function transform(Type $type) private function handleUsingExtensions(Type $type) { - return array_reduce( - $this->typeToSchemaExtensions, - function ($acc, $extensionClass) use ($type) { - $extension = new $extensionClass($this->infer, $this, $this->components); + /** @var Collection $extensions */ + $extensions = collect($this->typeToSchemaExtensions) + ->map(fn ($extensionClass) => new $extensionClass($this->infer, $this, $this->components)) + ->filter->shouldHandle($type) + ->values(); - if (! $extension->shouldHandle($type)) { - return $acc; - } + $referenceExtension = $extensions->last(); - /** @var Reference|null $reference */ - $reference = method_exists($extension, 'reference') - ? $extension->reference($type) - : null; + /** @var Reference|null $reference */ + $reference = $referenceExtension && method_exists($referenceExtension, 'reference') + ? $referenceExtension->reference($type) + : null; - if ($reference && $this->components->hasSchema($reference->fullName)) { - return $reference; - } + if ($reference && $this->components->hasSchema($reference->fullName)) { + return $reference; + } - if ($reference) { - $this->components->addSchema($reference->fullName, Schema::fromType(new UnknownType('Reference is being analyzed.'))); - } + if ($reference) { + $this->components->addSchema($reference->fullName, Schema::fromType(new UnknownType('Reference is being analyzed.'))); + } - if ($handledType = $extension->toSchema($type, $acc)) { - if ($reference) { - return $this->components->addSchema($reference->fullName, Schema::fromType($handledType)); - } + $handledType = $extensions + ->reduce(function ($acc, $extension) use ($type) { + return $extension->toSchema($type, $acc) ?: $acc; + }); - return $handledType; - } + if ($handledType && $reference) { + $reference = $this->components->addSchema($reference->fullName, Schema::fromType($handledType)); + } - /* - * If we couldn't handle a type, the reference is removed. - */ - if ($reference) { - $this->components->removeSchema($reference->fullName); - } + /* + * If we couldn't handle a type, the reference is removed. + */ + if (! $handledType && $reference) { + $this->components->removeSchema($reference->fullName); + } - return $acc; - } - ); + return $reference ?: $handledType; } public function toResponse(Type $type) diff --git a/src/Support/InferExtensions/JsonResourceExtension.php b/src/Support/InferExtensions/JsonResourceExtension.php index 4abecfdb..e75a3008 100644 --- a/src/Support/InferExtensions/JsonResourceExtension.php +++ b/src/Support/InferExtensions/JsonResourceExtension.php @@ -2,34 +2,39 @@ namespace Dedoc\Scramble\Support\InferExtensions; +use Dedoc\Scramble\Infer\Definition\ClassDefinition; use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent; use Dedoc\Scramble\Infer\Extensions\Event\PropertyFetchEvent; -use Dedoc\Scramble\Infer\Extensions\Event\StaticMethodCallEvent; use Dedoc\Scramble\Infer\Extensions\MethodReturnTypeExtension; use Dedoc\Scramble\Infer\Extensions\PropertyTypeExtension; -use Dedoc\Scramble\Infer\Extensions\StaticMethodReturnTypeExtension; +use Dedoc\Scramble\Infer\Scope\GlobalScope; use Dedoc\Scramble\Infer\Scope\Scope; use Dedoc\Scramble\Infer\Services\ReferenceTypeResolver; use Dedoc\Scramble\Support\Helpers\JsonResourceHelper; +use Dedoc\Scramble\Support\Type\ArrayItemType_; use Dedoc\Scramble\Support\Type\ArrayType; use Dedoc\Scramble\Support\Type\BooleanType; +use Dedoc\Scramble\Support\Type\FloatType; use Dedoc\Scramble\Support\Type\FunctionType; use Dedoc\Scramble\Support\Type\Generic; use Dedoc\Scramble\Support\Type\IntegerType; +use Dedoc\Scramble\Support\Type\KeyedArrayType; use Dedoc\Scramble\Support\Type\Literal\LiteralBooleanType; use Dedoc\Scramble\Support\Type\Literal\LiteralIntegerType; +use Dedoc\Scramble\Support\Type\Literal\LiteralStringType; +use Dedoc\Scramble\Support\Type\NullType; use Dedoc\Scramble\Support\Type\ObjectType; use Dedoc\Scramble\Support\Type\Reference\MethodCallReferenceType; use Dedoc\Scramble\Support\Type\Reference\PropertyFetchReferenceType; +use Dedoc\Scramble\Support\Type\StringType; use Dedoc\Scramble\Support\Type\Type; use Dedoc\Scramble\Support\Type\Union; -use Dedoc\Scramble\Support\Type\UnknownType; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MergeValue; use Illuminate\Http\Resources\MissingValue; -class JsonResourceExtension implements MethodReturnTypeExtension, PropertyTypeExtension, StaticMethodReturnTypeExtension +class JsonResourceExtension implements MethodReturnTypeExtension, PropertyTypeExtension { public function shouldHandle(ObjectType|string $type): bool { @@ -43,16 +48,19 @@ public function shouldHandle(ObjectType|string $type): bool public function getMethodReturnType(MethodCallEvent $event): ?Type { return match ($event->name) { - // @todo This should work automatically as toArray calls must be proxied to parents. - 'toArray' => ($event->getInstance()->name === JsonResource::class || ($event->getDefinition() && ! $event->getDefinition()->hasMethodDefinition('toArray'))) + 'toArray' => $event->methodDefiningClassName === JsonResource::class ? $this->getModelMethodReturn($event->getInstance()->name, 'toArray', $event->arguments, $event->scope) : null, + 'response', 'toResponse' => new Generic(JsonResponse::class, [$event->getInstance(), new LiteralIntegerType(200), new ArrayType]), 'whenLoaded' => count($event->arguments) === 1 ? Union::wrap([ - // Relationship type which does not really matter - new UnknownType('Skipped real relationship type extracting'), + $this->getModelPropertyType( + $event->getDefinition(), + $event->getArg('attribute', 0)->value ?? '', + $event->scope + ), new ObjectType(MissingValue::class), ]) : Union::wrap([ @@ -60,7 +68,7 @@ public function getMethodReturnType(MethodCallEvent $event): ?Type $this->value($event->getArg('default', 2, new ObjectType(MissingValue::class))), ]), - 'when' => Union::wrap([ + 'when', 'unless', 'whenPivotLoaded' => Union::wrap([ $this->value($event->getArg('value', 1)), $this->value($event->getArg('default', 2, new ObjectType(MissingValue::class))), ]), @@ -70,11 +78,66 @@ public function getMethodReturnType(MethodCallEvent $event): ?Type $this->value($event->getArg('value', 0)), ]), - 'mergeWhen' => new Generic(MergeValue::class, [ + 'mergeWhen', 'mergeUnless' => new Generic(MergeValue::class, [ new BooleanType, $this->value($event->getArg('value', 1)), ]), + 'whenHas', 'whenAppended' => count($event->arguments) === 1 + ? Union::wrap([$this->getModelPropertyType( + $event->getDefinition(), + $event->getArg('attribute', 0)->value ?? '', + $event->scope + ), new ObjectType(MissingValue::class)]) + : Union::wrap([ + ($valueType = $event->getArg('value', 1, new NullType)) instanceof NullType + ? $this->getModelPropertyType( + $event->getDefinition(), + $event->getArg('attribute', 0)->value ?? '', + $event->scope + ) + : $this->value($valueType), + $this->value($event->getArg('default', 2, new ObjectType(MissingValue::class))), + ]), + + 'whenNotNull' => Union::wrap([ + $this->value($this->removeNullFromUnion($event->getArg('value', 0))), + $this->value($event->getArg('default', 1, new ObjectType(MissingValue::class))), + ]), + + 'whenNull' => Union::wrap([ + new NullType, + $this->value($event->getArg('default', 1, new ObjectType(MissingValue::class))), + ]), + + 'whenAggregated' => count($event->arguments) <= 3 + ? Union::wrap([ + match ($event->getArg('aggregate', 2)?->value ?? '') { + 'count' => new IntegerType, + 'avg', 'sum' => new FloatType, + default => new StringType, + }, + $this->value($event->getArg('default', 4, new ObjectType(MissingValue::class))), + ]) + : Union::wrap([ + $this->value($event->getArg('value', 3)), + $this->value($event->getArg('default', 4, new ObjectType(MissingValue::class))), + ]), + + 'whenExistsLoaded' => count($event->arguments) === 1 + ? Union::wrap([new BooleanType, new ObjectType(MissingValue::class)]) + : Union::wrap([ + $this->value($event->getArg('value', 1)), + $this->value($event->getArg('default', 2, new ObjectType(MissingValue::class))), + ]), + + 'whenPivotLoadedAs' => Union::wrap([ + $this->value($event->getArg('value', 2)), + $this->value($event->getArg('default', 3, new ObjectType(MissingValue::class))), + ]), + + 'hasPivotLoaded', 'hasPivotLoadedAs' => new BooleanType, + 'whenCounted' => count($event->arguments) === 1 ? Union::wrap([new IntegerType, new ObjectType(MissingValue::class)]) : Union::wrap([ @@ -82,50 +145,33 @@ public function getMethodReturnType(MethodCallEvent $event): ?Type $this->value($event->getArg('default', 2, new ObjectType(MissingValue::class))), ]), + 'attributes' => $this->getAttributesMethodReturnType($event), + default => ! $event->getDefinition() || $event->getDefinition()->hasMethodDefinition($event->name) ? null : $this->proxyMethodCallToModel($event), }; } - public function getStaticMethodReturnType(StaticMethodCallEvent $event): ?Type - { - return match ($event->name) { - 'toArray' => $this->handleToArrayStaticCall($event), - default => null, - }; - } - public function getPropertyType(PropertyFetchEvent $event): ?Type { return match ($event->name) { 'resource' => JsonResourceHelper::modelType($event->getDefinition(), $event->scope), default => ! $event->getDefinition() || $event->getDefinition()->hasPropertyDefinition($event->name) ? null - : ReferenceTypeResolver::getInstance()->resolve( - $event->scope, - new PropertyFetchReferenceType( - JsonResourceHelper::modelType($event->getDefinition(), $event->scope), - $event->name, - ), - ), + : $this->getModelPropertyType($event->getDefinition(), $event->name, $event->scope), }; } - /** - * Note: In fact, this is not a static call to the JsonResource. This is how type inference system treats it for - * now, when analyzing parent::toArray() call. `parent::` becomes `JsonResource::`. So this should be fixed in - * future just for the sake of following how real code works. - */ - private function handleToArrayStaticCall(StaticMethodCallEvent $event): ?Type + private function getModelPropertyType(ClassDefinition $jsonResourceDefinition, string $name, Scope $scope) { - $contextClassName = $event->scope->context->classDefinition->name ?? null; - - if (! $contextClassName) { - return null; - } - - return $this->getModelMethodReturn($contextClassName, 'toArray', $event->arguments, $event->scope); + return ReferenceTypeResolver::getInstance()->resolve( + $scope, + new PropertyFetchReferenceType( + JsonResourceHelper::modelType($jsonResourceDefinition, $scope), + $name, + ), + ); } private function proxyMethodCallToModel(MethodCallEvent $event) @@ -147,4 +193,45 @@ private function value(Type $type) { return $type instanceof FunctionType ? $type->getReturnType() : $type; } + + private function removeNullFromUnion(Type $type) + { + $type = Union::wrap( + ReferenceTypeResolver::getInstance()->resolve(new GlobalScope, $type) + ); + + $types = $type instanceof Union ? $type->types : [$type]; + + return Union::wrap( + collect($types)->filter(fn ($t) => ! $t instanceof NullType)->values()->all() + ); + } + + private function getAttributesMethodReturnType(MethodCallEvent $event) + { + $argument = $event->getArg('attributes', 0); + + $value = $argument instanceof KeyedArrayType + ? collect($argument->items)->map(fn (ArrayItemType_ $t) => $t->value instanceof LiteralStringType ? $t->value->value : null)->filter()->values()->all() + : ($argument instanceof LiteralStringType ? $argument->value : []); + + $modelToArrayReturn = $this->getModelMethodReturn($event->getInstance()->name, 'toArray', $event->arguments, $event->scope); + + if (! $modelToArrayReturn instanceof KeyedArrayType) { + return new Generic(MergeValue::class, [ + new LiteralBooleanType(true), + new KeyedArrayType([]), + ]); + } + + return new Generic(MergeValue::class, [ + new LiteralBooleanType(true), + new KeyedArrayType( + collect($modelToArrayReturn->items) + ->filter(fn (ArrayItemType_ $t) => in_array($t->key, $value)) + ->values() + ->all() + ), + ]); + } } diff --git a/src/Support/InferExtensions/ModelExtension.php b/src/Support/InferExtensions/ModelExtension.php index 941b4591..06c8c3e2 100644 --- a/src/Support/InferExtensions/ModelExtension.php +++ b/src/Support/InferExtensions/ModelExtension.php @@ -4,7 +4,6 @@ use Carbon\Carbon; use Carbon\CarbonImmutable; -use Dedoc\Scramble\Infer\Definition\ClassDefinition; use Dedoc\Scramble\Infer\Extensions\Event\MethodCallEvent; use Dedoc\Scramble\Infer\Extensions\Event\PropertyFetchEvent; use Dedoc\Scramble\Infer\Extensions\MethodReturnTypeExtension; @@ -29,6 +28,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use Throwable; class ModelExtension implements MethodReturnTypeExtension, PropertyTypeExtension { @@ -154,10 +154,7 @@ public function getMethodReturnType(MethodCallEvent $event): ?Type return null; } - /** @var ClassDefinition $definition */ - $definition = $event->getDefinition(); - - if (array_key_exists('toArray', $definition?->methods ?: [])) { + if ($this->getRealToArrayMethodDefinitionClassName($event) !== Model::class) { return null; } @@ -208,4 +205,22 @@ private function getProtectedValue($obj, $name) return $array[$prefix.$name]; } + + /** + * Due to vendor classes being not analyzed for now, we may have a situation when defining class name in event is not + * truly represents the location of the method. But we want to make sure to get it right. + */ + private function getRealToArrayMethodDefinitionClassName(MethodCallEvent $event) + { + $className = $event->methodDefiningClassName ?: $event->getInstance()->name; + + try { + $reflectionMethod = new \ReflectionMethod($className, 'toArray'); + + return $reflectionMethod->getDeclaringClass()->getName(); + } catch (Throwable) { + } + + return $event->methodDefiningClassName; + } } diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index 664b2629..72652fe7 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -42,7 +42,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) try { $rulesResults = collect($this->extractRouteRequestValidationRules($routeInfo, $routeInfo->methodNode())); } catch (Throwable $exception) { - if (app()->environment('testing')) { + if (Scramble::shouldThrowOnError()) { throw $exception; } $description = $description->append('⚠️Cannot generate request documentation: '.$exception->getMessage()); diff --git a/src/Support/OperationExtensions/RequestEssentialsExtension.php b/src/Support/OperationExtensions/RequestEssentialsExtension.php index 3a604d3c..171ff0f8 100644 --- a/src/Support/OperationExtensions/RequestEssentialsExtension.php +++ b/src/Support/OperationExtensions/RequestEssentialsExtension.php @@ -26,14 +26,20 @@ use Dedoc\Scramble\Support\Type\Union; use Dedoc\Scramble\Support\Type\UnknownType; use Illuminate\Contracts\Container\BindingResolutionException; +use Illuminate\Contracts\Routing\UrlRoutable; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Model; -use Illuminate\Http\Request; +use Illuminate\Routing\ImplicitRouteBinding; use Illuminate\Routing\Route; +use Illuminate\Routing\Router; use Illuminate\Support\Arr; +use Illuminate\Support\Reflector; use Illuminate\Support\Str; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use ReflectionException; +use ReflectionFunction; +use ReflectionNamedType; use ReflectionParameter; class RequestEssentialsExtension extends OperationExtension @@ -145,32 +151,89 @@ private function getParametersFromString(?string $str) return Str::of($str)->matchAll('/\{(.*?)\}/')->values()->toArray(); } + /** + * The goal here is to get the mapping of route names specified in route path to the parameters + * used in a route definition. The mapping then is used to get more information about the parameters for + * the documentation. For example, the description from PHPDoc will be used for a route path parameter + * description. + * + * So given the route path `/emails/{email_id}/recipients/{recipient_id}` and the route's method: + * `public function show(Request $request, string $emailId, string $recipientId)`, we get the mapping: + * `['email_id' => 'emailId', 'recipient_id' => 'recipientId']`. + * + * The trick is to avoid mapping parameters like `Request $request`, but to correctly map the model bindings + * (and other potential kind of bindings). + * + * During this method implementation, Laravel implicit binding checks against snake cased parameters. + * + * @see ImplicitRouteBinding::getParameterName + */ private function getRoutePathParameters(RouteInfo $routeInfo) { [$route, $methodPhpDocNode] = [$routeInfo->route, $routeInfo->phpDoc()]; $paramNames = $route->parameterNames(); - $paramsWithRealNames = ($reflectionParams = collect($route->signatureParameters()) - ->filter(function (ReflectionParameter $v) { - if (($type = $v->getType()) && ($type instanceof \ReflectionNamedType) && ($typeName = $type->getName())) { - if (is_a($typeName, Request::class, true)) { - return false; - } + + $implicitlyBoundReflectionParams = collect() + ->union($route->signatureParameters(UrlRoutable::class)) + ->union($route->signatureParameters(['backedEnum' => true])) + ->keyBy('name'); + + $paramsValuesClasses = collect($paramNames) + ->mapWithKeys(function ($name) use ($implicitlyBoundReflectionParams) { + if ($explicitlyBoundParamType = $this->getExplicitlyBoundParamType($name)) { + return [$name => $explicitlyBoundParamType]; } - return true; - }) - ->values()) - ->map(fn (ReflectionParameter $v) => $v->name) - ->all(); + /** @var ReflectionParameter $implicitlyBoundParam */ + $implicitlyBoundParam = $implicitlyBoundReflectionParams->first( + fn (ReflectionParameter $p) => $p->name === $name || Str::snake($p->name) === $name, + ); - if (count($paramNames) !== count($paramsWithRealNames)) { - $paramsWithRealNames = $paramNames; - } + if ($implicitlyBoundParam) { + return [$name => Reflector::getParameterClassName($implicitlyBoundParam)]; + } + + return [ + $name => null, + ]; + }); + + $routeParams = collect($route->signatureParameters()); + + $checkingRouteSignatureParameters = $route->signatureParameters(); + $paramsToSignatureParametersNameMap = collect($paramNames) + ->mapWithKeys(function ($name) use ($paramsValuesClasses, &$checkingRouteSignatureParameters) { + $boundParamType = $paramsValuesClasses[$name]; + $mappedParameterReflection = collect($checkingRouteSignatureParameters) + ->first(function (ReflectionParameter $rp) use ($boundParamType) { + $type = $rp->getType(); + + if (! $type instanceof ReflectionNamedType || $type->isBuiltin()) { + return true; + } + + $className = Reflector::getParameterClassName($rp); + + return is_a($boundParamType, $className, true); + }); + + if ($mappedParameterReflection) { + $checkingRouteSignatureParameters = array_filter($checkingRouteSignatureParameters, fn ($v) => $v !== $mappedParameterReflection); + } + + return [ + $name => $mappedParameterReflection, + ]; + }); + + $paramsWithRealNames = $paramsToSignatureParametersNameMap + ->mapWithKeys(fn (?ReflectionParameter $reflectionParameter, $name) => [$name => $reflectionParameter?->name ?: $name]) + ->values(); $aliases = collect($paramNames)->mapWithKeys(fn ($name, $i) => [$name => $paramsWithRealNames[$i]])->all(); - $reflectionParamsByKeys = $reflectionParams->keyBy->name; + $reflectionParamsByKeys = $routeParams->keyBy->name; $phpDocTypehintParam = $methodPhpDocNode ? collect($methodPhpDocNode->getParamTagValues())->keyBy(fn (ParamTagValueNode $n) => Str::replace('$', '', $n->parameterName)) : collect(); @@ -181,7 +244,8 @@ private function getRoutePathParameters(RouteInfo $routeInfo) * 2. PhpDoc Typehint * 3. String (?) */ - $params = array_map(function (string $paramName) use ($routeInfo, $route, $aliases, $reflectionParamsByKeys, $phpDocTypehintParam) { + $params = array_map(function (string $paramName) use ($routeInfo, $route, $aliases, $reflectionParamsByKeys, $phpDocTypehintParam, $paramsValuesClasses) { + $originalParamName = $paramName; $paramName = $aliases[$paramName]; $description = $phpDocTypehintParam[$paramName]?->description ?? ''; @@ -192,6 +256,7 @@ private function getRoutePathParameters(RouteInfo $routeInfo) $route, $phpDocTypehintParam[$paramName] ?? null, $reflectionParamsByKeys[$paramName] ?? null, + $paramsValuesClasses[$originalParamName] ?? null, ); $param = Parameter::make($paramName, 'path') @@ -208,9 +273,45 @@ private function getRoutePathParameters(RouteInfo $routeInfo) return [$params, $aliases]; } - private function getParameterType(string $paramName, string $description, RouteInfo $routeInfo, Route $route, ?ParamTagValueNode $phpDocParam, ?ReflectionParameter $reflectionParam) + private function getExplicitlyBoundParamType(string $name): ?string { - $type = new UnknownType; + if (! $binder = app(Router::class)->getBindingCallback($name)) { + return null; + } + + try { + $reflection = new ReflectionFunction($binder); + } catch (ReflectionException) { + return null; + } + + if ($returnType = $reflection->getReturnType()) { + return $returnType instanceof ReflectionNamedType && ! $returnType->isBuiltin() + ? $returnType->getName() + : null; + } + + // in case this is a model binder + if ( + ($modelClass = $reflection->getClosureUsedVariables()['class'] ?? null) + && is_string($modelClass) + ) { + return $modelClass; + } + + return null; + } + + private function getParameterType( + string $paramName, + string $description, + RouteInfo $routeInfo, + Route $route, + ?ParamTagValueNode $phpDocParam, + ?ReflectionParameter $reflectionParam, + ?string $boundClass, + ) { + $type = $boundClass ? new ObjectType($boundClass) : new UnknownType; if ($routeInfo->reflectionMethod()) { $type->setAttribute('file', $routeInfo->reflectionMethod()->getFileName()); $type->setAttribute('line', $routeInfo->reflectionMethod()->getStartLine()); diff --git a/src/Support/OperationExtensions/ResponseExtension.php b/src/Support/OperationExtensions/ResponseExtension.php index 9933edc0..bdfc91e3 100644 --- a/src/Support/OperationExtensions/ResponseExtension.php +++ b/src/Support/OperationExtensions/ResponseExtension.php @@ -28,6 +28,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) $responses = collect($returnTypes) ->merge(optional($routeInfo->getMethodType())->exceptions ?: []) +// ->dd() ->map($this->openApiTransformer->toResponse(...)) ->filter() ->unique(fn ($response) => ($response instanceof Response ? $response->code : 'ref').':'.json_encode($response->toArray())) diff --git a/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php b/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php index ac799793..fd0b4131 100644 --- a/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php +++ b/src/Support/OperationExtensions/RulesExtractor/RulesMapper.php @@ -11,6 +11,8 @@ use Dedoc\Scramble\Support\Generator\Types\UnknownType; use Dedoc\Scramble\Support\Generator\TypeTransformer; use Dedoc\Scramble\Support\Type\ObjectType; +use Dedoc\Scramble\Support\Type\TypeHelper; +use Dedoc\Scramble\Support\Type\Union; use Illuminate\Support\Str; use Illuminate\Support\Stringable; use Illuminate\Validation\Rules\Enum; @@ -139,6 +141,13 @@ public function max(Type $type, $params) return $type; } + public function size(Type $type, $params) + { + $type = $this->min($type, $params); + + return $this->max($type, $params); + } + public function in(Type $type, $params) { return $type->enum( @@ -161,9 +170,26 @@ public function enum(Type $_, Enum $rule) $enumName = $getProtectedValue($rule, 'type'); - return $this->openApiTransformer->transform( - new ObjectType($enumName) - ); + $objectType = new ObjectType($enumName); + + $except = $getProtectedValue($rule, 'except'); + $only = $getProtectedValue($rule, 'only'); + + if ($except || $only) { + $cases = collect($enumName::cases()) + ->reject(fn ($case) => in_array($case, $except)) + ->filter(fn ($case) => ! $only || in_array($case, $only)); + + if (! isset($cases->first()?->value)) { + return new UnknownType("$enumName enum doesnt have values (only/except context)"); + } + + return $this->openApiTransformer->transform(Union::wrap( + $cases->map(fn ($c) => TypeHelper::createTypeFromValue($c->value))->all() + )); + } + + return $this->openApiTransformer->transform($objectType); } public function image(Type $type) diff --git a/src/Support/PhpDoc.php b/src/Support/PhpDoc.php index b57a9495..9554cb81 100644 --- a/src/Support/PhpDoc.php +++ b/src/Support/PhpDoc.php @@ -13,14 +13,30 @@ class PhpDoc { - public static function parse(string $docComment): PhpDocNode + private static function getTokenizerAndParser() { - $docComment = Str::replace(['@body'], '@var', $docComment); + if (class_exists(\PHPStan\PhpDocParser\ParserConfig::class)) { + $config = new \PHPStan\PhpDocParser\ParserConfig(usedAttributes: ['lines' => true, 'indexes' => true]); + $lexer = new Lexer($config); + $constExprParser = new ConstExprParser($config); + $typeParser = new TypeParser($config, $constExprParser); + $phpDocParser = new PhpDocParser($config, $typeParser, $constExprParser); + + return [$lexer, $phpDocParser]; + } $lexer = new Lexer; $constExprParser = new ConstExprParser; $typeParser = new TypeParser($constExprParser); - $phpDocParser = new PhpDocParser($typeParser, $constExprParser); + + return [$lexer, new PhpDocParser($typeParser, $constExprParser)]; + } + + public static function parse(string $docComment): PhpDocNode + { + $docComment = Str::replace(['@body'], '@var', $docComment); + + [$lexer, $phpDocParser] = static::getTokenizerAndParser(); $tokens = new TokenIterator($lexer->tokenize($docComment)); diff --git a/src/Support/SchemaClassDocReflector.php b/src/Support/SchemaClassDocReflector.php index fb4f8725..1920e6f0 100644 --- a/src/Support/SchemaClassDocReflector.php +++ b/src/Support/SchemaClassDocReflector.php @@ -9,6 +9,7 @@ use PhpParser\NameContext; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; class SchemaClassDocReflector @@ -30,10 +31,13 @@ public function getDescription(): string return trim(implode("\n", array_map( static function (PhpDocChildNode $child): string { $s = (string) $child; + if ($child instanceof PhpDocTagNode) { + $s = explode("\n", $s, 2)[1] ?? ''; + } return $s === '' ? '' : ' '.$s; }, - array_filter($this->phpDoc->children, fn ($n) => $n instanceof PhpDocTextNode) + array_filter($this->phpDoc->children, fn ($n) => $n instanceof PhpDocTextNode || $n instanceof PhpDocTagNode) ))); } diff --git a/src/Support/Type/EnumCaseType.php b/src/Support/Type/EnumCaseType.php new file mode 100644 index 00000000..9cf49ed9 --- /dev/null +++ b/src/Support/Type/EnumCaseType.php @@ -0,0 +1,23 @@ +toString() === $this->toString(); + } + + public function toString(): string + { + return "{$this->name}::{$this->caseName}"; + } +} diff --git a/src/Support/Type/ObjectType.php b/src/Support/Type/ObjectType.php index 520e523d..70396f43 100644 --- a/src/Support/Type/ObjectType.php +++ b/src/Support/Type/ObjectType.php @@ -51,19 +51,25 @@ public function getMethodDefinition(string $methodName, Scope $scope = new Globa return $classDefinition?->getMethodDefinition($methodName, $scope); } - public function getMethodReturnType(string $methodName, array $arguments = [], Scope $scope = new GlobalScope): ?Type + public function getMethodReturnType(string $methodName, array $arguments = [], Scope $scope = new GlobalScope): Type { + $classDefinition = $scope->index->getClassDefinition($this->name); + if ($returnType = app(ExtensionsBroker::class)->getMethodReturnType(new MethodCallEvent( instance: $this, name: $methodName, scope: $scope, arguments: $arguments, + methodDefiningClassName: $definingClassName = $classDefinition ? $classDefinition->getMethodDefiningClassName($methodName, $scope->index) : $this->name, ))) { return $returnType; } + /* + * For now, when parent class is in `vendor`, we may do not know that certain definition exists. + */ if (! $methodDefinition = $this->getMethodDefinition($methodName)) { - return null; + return new UnknownType("No method {$definingClassName}@{$methodName} definition found, it may be located in `vendor` which is not analyzed."); } $returnType = $methodDefinition->type->getReturnType(); diff --git a/tests/Files/SamplePostModel.php b/tests/Files/SamplePostModel.php index c4a35f39..b447b16c 100644 --- a/tests/Files/SamplePostModel.php +++ b/tests/Files/SamplePostModel.php @@ -15,6 +15,7 @@ class SamplePostModel extends Model protected $with = ['parent', 'children', 'user']; protected $casts = [ + 'read_time' => 'int', 'status' => Status::class, 'settings' => 'array', ]; diff --git a/tests/Infer/ClassConstFetchTypesTest.php b/tests/Infer/ClassConstFetchTypesTest.php index fd9ef3c2..8d0524d6 100644 --- a/tests/Infer/ClassConstFetchTypesTest.php +++ b/tests/Infer/ClassConstFetchTypesTest.php @@ -7,4 +7,11 @@ ['$var::class', 'string'], ['(new SomeType)::class', 'string(SomeType)'], ['SomeType::class', 'string(SomeType)'], + ['Enum_ClassConstFetchTypesTest::FOO', 'Enum_ClassConstFetchTypesTest::FOO'], ]); + +enum Enum_ClassConstFetchTypesTest: string +{ + case FOO = 'foo'; + case BAR = 'bar'; +} diff --git a/tests/Infer/ReferenceResolutionTest.php b/tests/Infer/ReferenceResolutionTest.php index e596d485..735b818e 100644 --- a/tests/Infer/ReferenceResolutionTest.php +++ b/tests/Infer/ReferenceResolutionTest.php @@ -4,6 +4,7 @@ use Dedoc\Scramble\Support\Type\Generic; use Dedoc\Scramble\Support\Type\UnknownType; +use Dedoc\Scramble\Tests\Infer\stubs\InvokableFoo; it('supports creating an object without constructor', function () { $type = analyzeFile(<<<'EOD' @@ -236,3 +237,15 @@ public function car() return 1; } } + +it('resolves invokable call from parent class', function () { + $type = analyzeClass(InvokableFoo::class)->getExpressionType('(new Dedoc\Scramble\Tests\Infer\stubs\InvokableFoo)("foo")'); + + expect($type->toString())->toBe('string(foo)'); +}); + +it('handles invokable call to Closure type without failing (#636)', function () { + $type = getStatementType('(new \Closure)("foo")'); + + expect($type->toString())->toBe('unknown'); +}); diff --git a/tests/Infer/stubs/InvokableFoo.php b/tests/Infer/stubs/InvokableFoo.php new file mode 100644 index 00000000..0eb509e9 --- /dev/null +++ b/tests/Infer/stubs/InvokableFoo.php @@ -0,0 +1,11 @@ +infer = app(Infer::class); +}); + +/** + * @return array{0: \Dedoc\Scramble\Support\Generator\Types\Type, 1: Components} + */ +function JsonResourceExtensionTest_analyze(Infer $infer, string $class) +{ + $transformer = new TypeTransformer($infer, $components = new Components, [ + ModelToSchema::class, + JsonResourceTypeToSchema::class, + ]); + $extension = new JsonResourceTypeToSchema($infer, $transformer, $components); + + $type = new ObjectType($class); + + $openApiType = $extension->toSchema($type); + + return [$openApiType, $components]; +} + +it('supports whenHas', function () { + [$schema] = JsonResourceExtensionTest_analyze($this->infer, JsonResourceExtensionTest_WhenHas::class); + + expect($schema->toArray())->toBe([ + 'type' => 'object', + 'properties' => [ + 'user' => [ + '$ref' => '#/components/schemas/SampleUserModel', + ], + 'value' => [ + 'type' => 'integer', + 'example' => 42, + ], + 'default' => [ + 'anyOf' => [ + [ + 'type' => 'string', + 'enum' => ['foo'], + ], + [ + 'type' => 'integer', + 'enum' => [42], + ], + ], + ], + ], + 'required' => ['default'], + ]); +}); +/** @mixin SamplePostModel */ +class JsonResourceExtensionTest_WhenHas extends JsonResource +{ + public function toArray(Request $request) + { + return [ + 'user' => $this->whenHas('user'), + 'value' => $this->whenHas('user', 42), + 'default' => $this->whenHas('user', 42, 'foo'), + ]; + } +} diff --git a/tests/InferExtensions/ModelExtensionTest.php b/tests/InferExtensions/ModelExtensionTest.php index 71a48767..58e191fc 100644 --- a/tests/InferExtensions/ModelExtensionTest.php +++ b/tests/InferExtensions/ModelExtensionTest.php @@ -49,8 +49,11 @@ it('adds toArray method type the model class without defined toArray class', function () { $this->infer->analyzeClass(SampleUserModel::class); + $scope = new Infer\Scope\GlobalScope; + $scope->index = $this->infer->index; + $toArrayReturnType = (new ObjectType(SampleUserModel::class)) - ->getMethodReturnType('toArray'); + ->getMethodReturnType('toArray', scope: $scope); expect(collect($toArrayReturnType->items)->mapWithKeys(fn (ArrayItemType_ $t) => [$t->key.($t->isOptional ? '?' : '') => $t->value->toString()])) ->toMatchArray([ diff --git a/tests/Support/OperationExtensions/RequestEssentialsExtensionTest.php b/tests/Support/OperationExtensions/RequestEssentialsExtensionTest.php index 0f26f862..acbccc21 100644 --- a/tests/Support/OperationExtensions/RequestEssentialsExtensionTest.php +++ b/tests/Support/OperationExtensions/RequestEssentialsExtensionTest.php @@ -51,6 +51,37 @@ class Foo_RequestRulesTest_Controller public function foo(ModelWithRulesMethod $model) {} } +it('uses explicit binding to infer more info about path params using Route::model', function () { + RouteFacade::model('model_bound', RequestEssentialsExtensionTest_SimpleModel::class); + + $openApiDocument = generateForRoute(function () { + return RouteFacade::get('api/test/{model_bound}', [Foo_RequestExplicitlyBoundTest_Controller::class, 'foo']); + }); + + expect($openApiDocument['paths']['/test/{model_bound}']['get']['parameters'][0]) + ->toHaveKey('schema.type', 'integer') + ->toHaveKey('description', 'The model bound ID'); +}); +it('uses explicit binding to infer more info about path params using Route::bind with typehint', function () { + RouteFacade::bind('model_bound', fn ($value): RequestEssentialsExtensionTest_SimpleModel => RequestEssentialsExtensionTest_SimpleModel::findOrFail($value)); + + $openApiDocument = generateForRoute(function () { + return RouteFacade::get('api/test/{model_bound}', [Foo_RequestExplicitlyBoundTest_Controller::class, 'foo']); + }); + + expect($openApiDocument['paths']['/test/{model_bound}']['get']['parameters'][0]) + ->toHaveKey('schema.type', 'integer') + ->toHaveKey('description', 'The model bound ID'); +}); +class RequestEssentialsExtensionTest_SimpleModel extends \Illuminate\Database\Eloquent\Model +{ + protected $table = 'users'; +} +class Foo_RequestExplicitlyBoundTest_Controller +{ + public function foo() {} +} + it('handles custom key from route to determine model route key type', function () { $openApiDocument = generateForRoute(function () { return RouteFacade::get('api/test/{user:name}', [CustomKey_RequestEssentialsExtensionTest_Controller::class, 'foo']); diff --git a/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php b/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php index a8692622..95dcf58b 100644 --- a/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php +++ b/tests/Support/TypeToSchemaExtensions/JsonResourceTypeToSchemaTest.php @@ -37,6 +37,16 @@ expect($extension->toSchema($type)->toArray())->toBe($expectedSchemaArray); })->with([ + [JsonResourceTypeToSchemaTest_NestedSample::class, [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + 'name' => ['type' => 'string'], + 'foo' => ['type' => 'string', 'example' => 'bar'], + 'nested' => ['type' => 'string', 'example' => 'true'], + ], + 'required' => ['id', 'name', 'foo', 'nested'], + ]], [JsonResourceTypeToSchemaTest_Sample::class, [ 'type' => 'object', 'properties' => [ @@ -72,7 +82,7 @@ class JsonResourceTypeToSchemaTest_NoToArraySample extends \Illuminate\Http\Reso */ class JsonResourceTypeToSchemaTest_SpreadSample extends \Illuminate\Http\Resources\Json\JsonResource { - public function toArray($request) + public function toArray($request): array { return [ ...parent::toArray($request), @@ -80,6 +90,19 @@ public function toArray($request) ]; } } +/** + * @property JsonResourceTypeToSchemaTest_User $resource + */ +class JsonResourceTypeToSchemaTest_NestedSample extends JsonResourceTypeToSchemaTest_SpreadSample +{ + public function toArray($request): array + { + return [ + ...parent::toArray($request), + 'nested' => 'true', + ]; + } +} /** * @property JsonResourceTypeToSchemaTest_User $resource */ diff --git a/tests/TestCase.php b/tests/TestCase.php index 7350c6b7..bac62acc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,6 +17,8 @@ protected function setUp(): void { parent::setUp(); + Scramble::throwOnError(); + $this->app->when(RulesToParameters::class) ->needs('$validationNodesResults') ->give([]); diff --git a/tests/ValidationRulesDocumentingTest.php b/tests/ValidationRulesDocumentingTest.php index 8c8ac4a7..cf2971ab 100644 --- a/tests/ValidationRulesDocumentingTest.php +++ b/tests/ValidationRulesDocumentingTest.php @@ -80,6 +80,38 @@ function validationRulesToDocumentationWithDeep(array $rules) assertMatchesSnapshot(collect($params)->map->toArray()->all()); }); +it('extract rules from enum rule with only', function () { + $rules = [ + 'status' => (new Enum(StatusValidationEnum::class))->only([StatusValidationEnum::DRAFT, StatusValidationEnum::ARCHIVED]), + ]; + + $params = app()->make(RulesToParameters::class, ['rules' => $rules])->handle(); + + expect($params[0]->toArray()['schema'])->toBe([ + 'type' => 'string', + 'enum' => [ + 'draft', + 'archived', + ], + ]); +}); + +it('extract rules from enum rule with except', function () { + $rules = [ + 'status' => (new Enum(StatusValidationEnum::class))->except(StatusValidationEnum::DRAFT), + ]; + + $params = app()->make(RulesToParameters::class, ['rules' => $rules])->handle(); + + expect($params[0]->toArray()['schema'])->toBe([ + 'type' => 'string', + 'enum' => [ + 'published', + 'archived', + ], + ]); +}); + it('extract rules from object like rules', function () { $rules = [ 'channels.agency' => 'nullable',