diff --git a/src/Configuration/ParametersExtractors.php b/src/Configuration/ParametersExtractors.php index 22d87230..b63ffec7 100644 --- a/src/Configuration/ParametersExtractors.php +++ b/src/Configuration/ParametersExtractors.php @@ -3,6 +3,7 @@ namespace Dedoc\Scramble\Configuration; use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\FormRequestParametersExtractor; +use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\PathParametersExtractor; use Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor\ValidateCallParametersExtractor; use Illuminate\Support\Arr; @@ -44,6 +45,7 @@ public function use(array $extractors) public function all(): array { $base = $this->extractors ?: [ + PathParametersExtractor::class, FormRequestParametersExtractor::class, ValidateCallParametersExtractor::class, ]; diff --git a/src/Reflection/ReflectionRoute.php b/src/Reflection/ReflectionRoute.php new file mode 100644 index 00000000..dc98e7e7 --- /dev/null +++ b/src/Reflection/ReflectionRoute.php @@ -0,0 +1,153 @@ + '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 + */ + public function getSignatureParametersMap(): array + { + $paramNames = $this->route->parameterNames(); + + $paramBoundTypes = $this->getBoundParametersTypes(); + + $checkingRouteSignatureParameters = $this->route->signatureParameters(); + $paramsToSignatureParametersNameMap = collect($paramNames) + ->mapWithKeys(function ($name) use ($paramBoundTypes, &$checkingRouteSignatureParameters) { + $boundParamType = $paramBoundTypes[$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(); + + return collect($paramNames)->mapWithKeys(fn ($name, $i) => [$name => $paramsWithRealNames[$i]])->all(); + } + + /** + * Get bound parameters types – these are the name of classes that can be bound to the parameters. + * This includes implicitly bound types (UrlRoutable, backedEnum) and explicitly bound parameters. + * + * @return array + */ + public function getBoundParametersTypes(): array + { + $paramNames = $this->route->parameterNames(); + + $implicitlyBoundReflectionParams = collect() + ->union($this->route->signatureParameters(UrlRoutable::class)) + ->union($this->route->signatureParameters(['backedEnum' => true])) + ->keyBy('name'); + + return collect($paramNames) + ->mapWithKeys(function ($name) use ($implicitlyBoundReflectionParams) { + if ($explicitlyBoundParamType = $this->getExplicitlyBoundParamType($name)) { + return [$name => $explicitlyBoundParamType]; + } + + /** @var ReflectionParameter $implicitlyBoundParam */ + $implicitlyBoundParam = $implicitlyBoundReflectionParams->first( + fn (ReflectionParameter $p) => $p->name === $name || Str::snake($p->name) === $name, + ); + + if ($implicitlyBoundParam) { + return [$name => Reflector::getParameterClassName($implicitlyBoundParam)]; + } + + return [ + $name => null, + ]; + }) + ->all(); + } + + private function getExplicitlyBoundParamType(string $name): ?string + { + 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; + } +} diff --git a/src/Support/OperationExtensions/ParameterExtractor/AttributesParametersExtractor.php b/src/Support/OperationExtensions/ParameterExtractor/AttributesParametersExtractor.php index 79da93b9..6e63cfda 100644 --- a/src/Support/OperationExtensions/ParameterExtractor/AttributesParametersExtractor.php +++ b/src/Support/OperationExtensions/ParameterExtractor/AttributesParametersExtractor.php @@ -35,11 +35,11 @@ public function handle(RouteInfo $routeInfo, array $parameterExtractionResults): ->map(fn (ReflectionAttribute $ra) => $this->createParameter($parameterExtractionResults, $ra->newInstance(), $ra->getArguments())) ->all(); - $extractedAttributes = collect($parameters)->map->name->all(); + $extractedAttributes = collect($parameters)->map(fn ($p) => "$p->name.$p->in")->all(); foreach ($parameterExtractionResults as $automaticallyExtractedParameters) { $automaticallyExtractedParameters->parameters = collect($automaticallyExtractedParameters->parameters) - ->filter(fn (Parameter $p) => ! in_array($p->name, $extractedAttributes)) + ->filter(fn (Parameter $p) => ! in_array("$p->name.$p->in", $extractedAttributes)) ->values() ->all(); } diff --git a/src/Support/OperationExtensions/ParameterExtractor/PathParametersExtractor.php b/src/Support/OperationExtensions/ParameterExtractor/PathParametersExtractor.php new file mode 100644 index 00000000..119e8571 --- /dev/null +++ b/src/Support/OperationExtensions/ParameterExtractor/PathParametersExtractor.php @@ -0,0 +1,194 @@ +route); + + $methodPhpDocNode = $routeInfo->phpDoc(); + $aliases = $reflectionRoute->getSignatureParametersMap(); + $routeParams = collect($route->signatureParameters()); + $reflectionParamsByKeys = $routeParams->keyBy->name; + $paramsValuesClasses = $reflectionRoute->getBoundParametersTypes(); + $phpDocTypehintParam = $methodPhpDocNode + ? collect($methodPhpDocNode->getParamTagValues())->keyBy(fn (ParamTagValueNode $n) => Str::replace('$', '', $n->parameterName)) + : collect(); + + /* + * Figure out param type based on importance priority: + * 1. Typehint (reflection) + * 2. PhpDoc Typehint + * 3. String (?) + */ + $parameters = array_map(function (string $paramName) use ($routeInfo, $route, $aliases, $reflectionParamsByKeys, $phpDocTypehintParam, $paramsValuesClasses) { + $originalParamName = $paramName; + $paramName = $aliases[$paramName]; + + $description = $phpDocTypehintParam[$paramName]?->description ?? ''; + [$schemaType, $description, $isOptional] = $this->getParameterType( + $paramName, + $description, + $routeInfo, + $route, + $phpDocTypehintParam[$paramName] ?? null, + $reflectionParamsByKeys[$paramName] ?? null, + $paramsValuesClasses[$originalParamName] ?? null, + ); + + $param = Parameter::make($paramName, 'path') + ->description($description) + ->setSchema(Schema::fromType($schemaType)); + + if ($isOptional) { + $param->setExtensionProperty('optional', true); + } + + $param->setAttribute('nonBody', true); + + return $param; + }, array_values(array_diff($route->parameterNames(), $this->getParametersFromString($route->getDomain())))); + + return [...$parameterExtractionResults, new ParametersExtractionResult($parameters)]; + } + + private function getParametersFromString(?string $str) + { + return Str::of($str)->matchAll('/\{(.*?)\}/')->values()->toArray(); + } + + 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()); + } + + if ($phpDocParam?->type) { + $type = PhpDocTypeHelper::toType($phpDocParam->type); + } + + if ($reflectionParam?->hasType()) { + $type = TypeHelper::createTypeFromReflectionType($reflectionParam->getType()); + } + + $simplifiedType = Union::wrap(array_map( + fn (InferType $t) => $t instanceof ObjectType + ? (enum_exists($t->name) ? $t : new \Dedoc\Scramble\Support\Type\StringType) + : $t, + $type instanceof Union ? $type->types : [$type], + )); + + $schemaType = $this->openApiTransformer->transform($simplifiedType); + + if ($isModelId = $type instanceof ObjectType) { + [$schemaType, $description] = $this->getModelIdTypeAndDescription($schemaType, $type, $paramName, $description, $route->bindingFields()[$paramName] ?? null); + + $schemaType->setAttribute('isModelId', true); + } + + if ($schemaType instanceof \Dedoc\Scramble\Support\Generator\Types\UnknownType) { + $schemaType = (new StringType)->mergeAttributes($schemaType->attributes()); + } + + if ($reflectionParam?->isDefaultValueAvailable()) { + $schemaType->default($reflectionParam->getDefaultValue()); + } + + $description ??= ''; + + if ($isOptional = Str::contains($route->uri(), ['{'.$paramName.'?}', '{'.Str::snake($paramName).'?}'], ignoreCase: true)) { + $description = implode('. ', array_filter(['**Optional**', $description])); + } + + return [$schemaType, $description, $isOptional]; + } + + private function getModelIdTypeAndDescription( + Type $baseType, + InferType $type, + string $paramName, + string $description, + ?string $bindingField, + ): array { + $defaults = [ + $baseType, + $description ?: 'The '.Str::of($paramName)->kebab()->replace(['-', '_'], ' ').' ID', + ]; + + if (! $type->isInstanceOf(Model::class)) { + return $defaults; + } + + /** @var ObjectType $type */ + $defaults[0] = $this->openApiTransformer->transform(new IntegerType); + + try { + /** @var Model $modelInstance */ + $modelInstance = resolve($type->name); + } catch (BindingResolutionException) { + return $defaults; + } + + $modelKeyName = $modelInstance->getKeyName(); + $routeKeyName = $bindingField ?: $modelInstance->getRouteKeyName(); + + if ($description === '') { + $keyDescriptionName = in_array($routeKeyName, ['id', 'uuid']) + ? Str::upper($routeKeyName) + : (string) Str::of($routeKeyName)->lower()->kebab()->replace(['-', '_'], ' '); + + $description = 'The '.Str::of($paramName)->kebab()->replace(['-', '_'], ' ').' '.$keyDescriptionName; + } + + $modelTraits = class_uses($type->name); + if ($routeKeyName === $modelKeyName && Arr::has($modelTraits, HasUuids::class)) { + return [(new StringType)->format('uuid'), $description]; + } + + $propertyType = $type->getPropertyType($routeKeyName); + if ($propertyType instanceof UnknownType) { + $propertyType = new IntegerType; + } + + return [$this->openApiTransformer->transform($propertyType), $description]; + } +} diff --git a/src/Support/OperationExtensions/RequestBodyExtension.php b/src/Support/OperationExtensions/RequestBodyExtension.php index d334f5cb..0d8efb0f 100644 --- a/src/Support/OperationExtensions/RequestBodyExtension.php +++ b/src/Support/OperationExtensions/RequestBodyExtension.php @@ -51,7 +51,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) ->summary(Str::of($routeInfo->phpDoc()->getAttribute('summary'))->rtrim('.')) ->description($description); - $allParams = $rulesResults->flatMap->parameters->unique('name')->values()->all(); + $allParams = $rulesResults->flatMap->parameters->unique(fn ($p) => "$p->name.$p->in")->values()->all(); $mediaType = $this->getMediaType($operation, $routeInfo, $allParams); @@ -81,15 +81,14 @@ public function handle(Operation $operation, RouteInfo $routeInfo) $schemalessResults = collect([$this->mergeSchemalessRulesResults($schemalessResults->values())]); $schemas = $schemaResults->merge($schemalessResults) - ->filter(fn (ParametersExtractionResult $r) => count($r->parameters) || $r->schemaName) ->map(function (ParametersExtractionResult $r) use ($nonBodyParams) { - $qpNames = collect($nonBodyParams)->keyBy('name'); + $qpNames = collect($nonBodyParams)->keyBy(fn ($p) => "$p->name.$p->in"); - $r->parameters = collect($r->parameters)->filter(fn ($p) => ! $qpNames->has($p->name))->values()->all(); + $r->parameters = collect($r->parameters)->filter(fn ($p) => ! $qpNames->has("$p->name.$p->in"))->values()->all(); return $r; }) - ->values() + ->filter(fn (ParametersExtractionResult $r) => count($r->parameters) || $r->schemaName) ->map($this->makeSchemaFromResults(...)); if ($schemas->isEmpty()) { @@ -155,7 +154,7 @@ protected function makeComposedRequestBodySchema(Collection $schemas) protected function mergeSchemalessRulesResults(Collection $schemalessResults): ParametersExtractionResult { return new ParametersExtractionResult( - parameters: $this->convertDotNamedParamsToComplexStructures($schemalessResults->values()->flatMap->parameters->unique('name')->values()->all()), + parameters: $this->convertDotNamedParamsToComplexStructures($schemalessResults->values()->flatMap->parameters->unique(fn ($p) => "$p->name.$p->in")->values()->all()), ); } diff --git a/src/Support/OperationExtensions/RequestEssentialsExtension.php b/src/Support/OperationExtensions/RequestEssentialsExtension.php index bf7d164e..9c577fe8 100644 --- a/src/Support/OperationExtensions/RequestEssentialsExtension.php +++ b/src/Support/OperationExtensions/RequestEssentialsExtension.php @@ -6,43 +6,20 @@ use Dedoc\Scramble\Extensions\OperationExtension; use Dedoc\Scramble\GeneratorConfig; use Dedoc\Scramble\Infer; -use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper; +use Dedoc\Scramble\Reflection\ReflectionRoute; use Dedoc\Scramble\Scramble; use Dedoc\Scramble\Support\Generator\OpenApi; use Dedoc\Scramble\Support\Generator\Operation; -use Dedoc\Scramble\Support\Generator\Parameter; -use Dedoc\Scramble\Support\Generator\Schema; use Dedoc\Scramble\Support\Generator\Server; -use Dedoc\Scramble\Support\Generator\ServerVariable; -use Dedoc\Scramble\Support\Generator\Types\StringType; -use Dedoc\Scramble\Support\Generator\Types\Type; use Dedoc\Scramble\Support\Generator\TypeTransformer; use Dedoc\Scramble\Support\Generator\UniqueNameOptions; use Dedoc\Scramble\Support\PhpDoc; use Dedoc\Scramble\Support\RouteInfo; use Dedoc\Scramble\Support\ServerFactory; -use Dedoc\Scramble\Support\Type\IntegerType; -use Dedoc\Scramble\Support\Type\ObjectType; -use Dedoc\Scramble\Support\Type\Type as InferType; -use Dedoc\Scramble\Support\Type\TypeHelper; -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\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 { @@ -78,7 +55,7 @@ private function getDefaultTags(Operation $operation, RouteInfo $routeInfo) public function handle(Operation $operation, RouteInfo $routeInfo) { - [$pathParams, $pathAliases] = $this->getRoutePathParameters($routeInfo); + $pathAliases = ReflectionRoute::createFromRoute($routeInfo->route)->getSignatureParametersMap(); $tagResolver = Scramble::$tagResolver ?? fn () => $this->getDefaultTags($operation, $routeInfo); @@ -92,8 +69,7 @@ public function handle(Operation $operation, RouteInfo $routeInfo) $uriWithoutOptionalParams, )) ->setTags($tagResolver($routeInfo, $operation)) - ->servers($this->getAlternativeServers($routeInfo->route)) - ->addParameters($pathParams); + ->servers($this->getAlternativeServers($routeInfo->route)); if (count($routeInfo->phpDoc()->getTagsByName('@unauthenticated'))) { $operation->security = []; @@ -164,268 +140,6 @@ private function extractTagsForMethod(RouteInfo $routeInfo) return explode(',', array_values($tagNodes)[0]->value->value); } - 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(); - - $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]; - } - - /** @var ReflectionParameter $implicitlyBoundParam */ - $implicitlyBoundParam = $implicitlyBoundReflectionParams->first( - fn (ReflectionParameter $p) => $p->name === $name || Str::snake($p->name) === $name, - ); - - 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 = $routeParams->keyBy->name; - $phpDocTypehintParam = $methodPhpDocNode - ? collect($methodPhpDocNode->getParamTagValues())->keyBy(fn (ParamTagValueNode $n) => Str::replace('$', '', $n->parameterName)) - : collect(); - - /* - * Figure out param type based on importance priority: - * 1. Typehint (reflection) - * 2. PhpDoc Typehint - * 3. String (?) - */ - $params = array_map(function (string $paramName) use ($routeInfo, $route, $aliases, $reflectionParamsByKeys, $phpDocTypehintParam, $paramsValuesClasses) { - $originalParamName = $paramName; - $paramName = $aliases[$paramName]; - - $description = $phpDocTypehintParam[$paramName]?->description ?? ''; - [$schemaType, $description, $isOptional] = $this->getParameterType( - $paramName, - $description, - $routeInfo, - $route, - $phpDocTypehintParam[$paramName] ?? null, - $reflectionParamsByKeys[$paramName] ?? null, - $paramsValuesClasses[$originalParamName] ?? null, - ); - - $param = Parameter::make($paramName, 'path') - ->description($description) - ->setSchema(Schema::fromType($schemaType)); - - if ($isOptional) { - $param->setExtensionProperty('optional', true); - } - - return $param; - }, array_values(array_diff($route->parameterNames(), $this->getParametersFromString($route->getDomain())))); - - return [$params, $aliases]; - } - - private function getExplicitlyBoundParamType(string $name): ?string - { - 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()); - } - - if ($phpDocParam?->type) { - $type = PhpDocTypeHelper::toType($phpDocParam->type); - } - - if ($reflectionParam?->hasType()) { - $type = TypeHelper::createTypeFromReflectionType($reflectionParam->getType()); - } - - $simplifiedType = Union::wrap(array_map( - fn (InferType $t) => $t instanceof ObjectType - ? (enum_exists($t->name) ? $t : new \Dedoc\Scramble\Support\Type\StringType) - : $t, - $type instanceof Union ? $type->types : [$type], - )); - - $schemaType = $this->openApiTransformer->transform($simplifiedType); - - if ($isModelId = $type instanceof ObjectType) { - [$schemaType, $description] = $this->getModelIdTypeAndDescription($schemaType, $type, $paramName, $description, $route->bindingFields()[$paramName] ?? null); - - $schemaType->setAttribute('isModelId', true); - } - - if ($schemaType instanceof \Dedoc\Scramble\Support\Generator\Types\UnknownType) { - $schemaType = (new StringType)->mergeAttributes($schemaType->attributes()); - } - - if ($reflectionParam?->isDefaultValueAvailable()) { - $schemaType->default($reflectionParam->getDefaultValue()); - } - - $description ??= ''; - - $isOptional = false; - if ($isOptional = Str::contains($route->uri(), ['{'.$paramName.'?}', '{'.Str::snake($paramName).'?}'], ignoreCase: true)) { - $description = implode('. ', array_filter(['**Optional**', $description])); - } - - return [$schemaType, $description, $isOptional]; - } - - private function getModelIdTypeAndDescription( - Type $baseType, - InferType $type, - string $paramName, - string $description, - ?string $bindingField, - ): array { - $defaults = [ - $baseType, - $description ?: 'The '.Str::of($paramName)->kebab()->replace(['-', '_'], ' ').' ID', - ]; - - if (! $type->isInstanceOf(Model::class)) { - return $defaults; - } - - /** @var ObjectType $type */ - $defaults[0] = $this->openApiTransformer->transform(new IntegerType); - - try { - /** @var Model $modelInstance */ - $modelInstance = resolve($type->name); - } catch (BindingResolutionException) { - return $defaults; - } - - $modelKeyName = $modelInstance->getKeyName(); - $routeKeyName = $bindingField ?: $modelInstance->getRouteKeyName(); - - if ($description === '') { - $keyDescriptionName = in_array($routeKeyName, ['id', 'uuid']) - ? Str::upper($routeKeyName) - : (string) Str::of($routeKeyName)->lower()->kebab()->replace(['-', '_'], ' '); - - $description = 'The '.Str::of($paramName)->kebab()->replace(['-', '_'], ' ').' '.$keyDescriptionName; - } - - $modelTraits = class_uses($type->name); - if ($routeKeyName === $modelKeyName && Arr::has($modelTraits, HasUuids::class)) { - return [(new StringType)->format('uuid'), $description]; - } - - $propertyType = $type->getPropertyType($routeKeyName); - if ($propertyType instanceof UnknownType) { - $propertyType = new IntegerType; - } - - return [$this->openApiTransformer->transform($propertyType), $description]; - } - private function getOperationId(RouteInfo $routeInfo) { $routeClassName = $routeInfo->className() ?: ''; @@ -464,18 +178,4 @@ private function getOperationId(RouteInfo $routeInfo) ->toArray(), ); } - - private function getServerVariablesInUrl(string $url): array - { - $variables = $this->config->serverVariables->all(); - - $params = Str::of($url)->matchAll('/\{(.*?)\}/'); - - return collect($variables) - ->only($params) - ->merge($params->reject(fn ($p) => array_key_exists($p, $variables))->mapWithKeys(fn ($p) => [ - $p => ServerVariable::make('example'), - ])) - ->toArray(); - } } diff --git a/src/Support/OperationExtensions/RulesExtractor/DeepParametersMerger.php b/src/Support/OperationExtensions/RulesExtractor/DeepParametersMerger.php index 70763ff0..b987afcd 100644 --- a/src/Support/OperationExtensions/RulesExtractor/DeepParametersMerger.php +++ b/src/Support/OperationExtensions/RulesExtractor/DeepParametersMerger.php @@ -17,7 +17,9 @@ public function __construct(private Collection $parameters) {} public function handle() { - return $this->handleNested($this->parameters->keyBy('name')) + return $this->parameters->groupBy('in') + ->map(fn ($parameters) => $this->handleNested($parameters->keyBy('name'))->values()) + ->flatten() ->values() ->all(); } diff --git a/tests/Attributes/ParameterAnnotationsTest.php b/tests/Attributes/ParameterAnnotationsTest.php index 6fcebdef..c57f0880 100644 --- a/tests/Attributes/ParameterAnnotationsTest.php +++ b/tests/Attributes/ParameterAnnotationsTest.php @@ -5,6 +5,8 @@ use Dedoc\Scramble\Attributes\Example; use Dedoc\Scramble\Attributes\HeaderParameter; use Dedoc\Scramble\Attributes\Parameter; +use Dedoc\Scramble\Attributes\PathParameter; +use Dedoc\Scramble\Attributes\QueryParameter; use Illuminate\Http\Request; use Illuminate\Routing\Router; @@ -27,6 +29,26 @@ class ParameterController_ParameterAnnotationsTest public function __invoke() {} } +it('supports path parameters attributes', function () { + $openApi = generateForRoute(fn (Router $r) => $r->get('api/test/{testId}', ParameterController_PathParameterTest::class)); + + expect($openApi['paths']['/test/{testId}']['get']['parameters'][0]) + ->toBe([ + 'name' => 'testId', + 'in' => 'path', + 'required' => true, + 'description' => 'Nice test ID', + 'schema' => [ + 'type' => 'string', + ], + ]); +}); +class ParameterController_PathParameterTest +{ + #[PathParameter('testId', 'Nice test ID')] + public function __invoke(string $testId) {} +} + it('supports simple example for Parameter annotations', function () { $openApi = generateForRoute(fn (Router $r) => $r->get('api/test', ParameterSimpleExampleController_ParameterAnnotationsTest::class)); @@ -47,6 +69,39 @@ class ParameterSimpleExampleController_ParameterAnnotationsTest public function __invoke() {} } +it('allows annotating parameters with the same names', function () { + $openApi = generateForRoute(fn (Router $r) => $r->get('api/test', SameNameParametersController_ParameterAnnotationsTest::class)); + + expect($parameters = $openApi['paths']['/test']['get']['parameters']) + ->toHaveCount(2) + ->and($parameters[0]['name'])->toBe('per_page') + ->and($parameters[1]['name'])->toBe('per_page') + ->and($parameters[0]['in'])->toBe('query') + ->and($parameters[1]['in'])->toBe('header'); +}); +class SameNameParametersController_ParameterAnnotationsTest +{ + #[QueryParameter('per_page')] + #[HeaderParameter('per_page')] + public function __invoke() {} +} + +it('allows defining parameters with the same names as inferred in different locations', function () { + $openApi = generateForRoute(fn (Router $r) => $r->get('api/test/{test}', SameNameParametersAsInferredController_ParameterAnnotationsTest::class)); + + expect($parameters = $openApi['paths']['/test/{test}']['get']['parameters']) + ->toHaveCount(2) + ->and($parameters[0]['name'])->toBe('test') + ->and($parameters[1]['name'])->toBe('test') + ->and($parameters[0]['in'])->toBe('path') + ->and($parameters[1]['in'])->toBe('query'); +}); +class SameNameParametersAsInferredController_ParameterAnnotationsTest +{ + #[QueryParameter('test')] + public function __invoke(string $test) {} +} + it('supports complex examples for Parameter annotations', function () { $openApi = generateForRoute(fn (Router $r) => $r->get('api/test', ParameterComplexExampleController_ParameterAnnotationsTest::class)); diff --git a/tests/Reflection/ReflectionRouteTest.php b/tests/Reflection/ReflectionRouteTest.php new file mode 100644 index 00000000..3e1443b3 --- /dev/null +++ b/tests/Reflection/ReflectionRouteTest.php @@ -0,0 +1,44 @@ + null); + + $ra = ReflectionRoute::createFromRoute($route); + $rb = ReflectionRoute::createFromRoute($route); + + expect($ra === $rb)->toBeTrue(); +}); + +test('gets params aliases single instance from route', function () { + $route = RouteFacade::get('{test_id}', fn (string $testId) => null); + + expect(ReflectionRoute::createFromRoute($route)->getSignatureParametersMap())->toBe([ + 'test_id' => 'testId', + ]); +}); + +test('gets params aliases without request from route', function () { + $route = RouteFacade::get('{test_id}', fn (Request $request, string $testId) => null); + + expect(ReflectionRoute::createFromRoute($route)->getSignatureParametersMap())->toBe([ + 'test_id' => 'testId', + ]); +}); + +test('gets bound params types', function () { + $r = ReflectionRoute::createFromRoute( + RouteFacade::get('{test_id}', fn (Request $request, User_ReflectionRouteTest $testId) => null) + ); + + expect($r->getBoundParametersTypes())->toBe([ + 'test_id' => User_ReflectionRouteTest::class, + ]); +}); +class User_ReflectionRouteTest extends Model {}