Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixed #[PathParameter] to override inferred path parameters #724

Merged
merged 5 commits into from
Feb 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Configuration/ParametersExtractors.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -44,6 +45,7 @@ public function use(array $extractors)
public function all(): array
{
$base = $this->extractors ?: [
PathParametersExtractor::class,
FormRequestParametersExtractor::class,
ValidateCallParametersExtractor::class,
];
Expand Down
153 changes: 153 additions & 0 deletions src/Reflection/ReflectionRoute.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

namespace Dedoc\Scramble\Reflection;

use Illuminate\Contracts\Routing\UrlRoutable;
use Illuminate\Routing\Route;
use Illuminate\Routing\Router;
use Illuminate\Support\Reflector;
use Illuminate\Support\Str;
use ReflectionException;
use ReflectionFunction;
use ReflectionNamedType;
use ReflectionParameter;
use WeakMap;

/**
* @internal
*/
class ReflectionRoute
{
private static WeakMap $cache;

private function __construct(private Route $route) {}

public static function createFromRoute(Route $route): static
{
static::$cache ??= new WeakMap;

return static::$cache[$route] ??= new static($route);
}

/**
* 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
*/
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<string, string|null>
*/
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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

namespace Dedoc\Scramble\Support\OperationExtensions\ParameterExtractor;

use Dedoc\Scramble\PhpDoc\PhpDocTypeHelper;
use Dedoc\Scramble\Reflection\ReflectionRoute;
use Dedoc\Scramble\Support\Generator\Parameter;
use Dedoc\Scramble\Support\Generator\Schema;
use Dedoc\Scramble\Support\Generator\Types\StringType;
use Dedoc\Scramble\Support\Generator\Types\Type;
use Dedoc\Scramble\Support\Generator\TypeTransformer;
use Dedoc\Scramble\Support\OperationExtensions\RulesExtractor\ParametersExtractionResult;
use Dedoc\Scramble\Support\RouteInfo;
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\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use ReflectionParameter;

class PathParametersExtractor implements ParameterExtractor
{
public function __construct(
private TypeTransformer $openApiTransformer,
) {}

public function handle(RouteInfo $routeInfo, array $parameterExtractionResults): array
{
$reflectionRoute = ReflectionRoute::createFromRoute($route = $routeInfo->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];
}
}
Loading