Skip to content

Commit

Permalink
Fixed #[PathParameter] to override inferred path parameters (#724)
Browse files Browse the repository at this point in the history
* refactor path parameters

* implemented path parameters

* Fix styling

* fixed parameters handling taking in into consideration

* handle both inferred and manually defined parameters

---------

Co-authored-by: romalytvynenko <[email protected]>
  • Loading branch information
romalytvynenko and romalytvynenko authored Feb 12, 2025
1 parent 88314d2 commit 8865094
Show file tree
Hide file tree
Showing 9 changed files with 461 additions and 312 deletions.
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

0 comments on commit 8865094

Please sign in to comment.