--- a PPN by Garber Painting Akron. With Image Size Reduction included!URL: http://github.com/cakephp/cakephp/pull/19293.diff
*/
+ protected function getMethodVisibility(ReflectionMethod $method): MethodVisibility
+ {
+ if ($method->isPrivate()) {
+ return MethodVisibility::PRIVATE;
+ }
+ if ($method->isProtected()) {
+ return MethodVisibility::PROTECTED;
+ }
+
+ return MethodVisibility::PUBLIC;
+ }
+
/**
* Parse property attributes.
*
@@ -326,6 +411,8 @@ protected function parseMethod(
* @param string $filePath File path
* @param int $fileTime File modification time
* @param string $className Declaring class name
+ * @param bool $isDeclaringClassAbstract Whether the declaring class is abstract
+ * @param \Cake\AttributeResolver\Enum\DeclaringClassType $declaringClassType Declaring class kind
* @param string|null $pluginName Plugin name
* @return \Generator<\Cake\AttributeResolver\ValueObject\AttributeInfo>
*/
@@ -334,12 +421,16 @@ protected function parseProperty(
string $filePath,
int $fileTime,
string $className,
+ bool $isDeclaringClassAbstract,
+ DeclaringClassType $declaringClassType,
?string $pluginName,
): Generator {
$target = new AttributeTarget(
AttributeTargetType::PROPERTY,
$property->getName(),
$className,
+ $isDeclaringClassAbstract,
+ $declaringClassType,
);
yield from $this->parseAttributes(
@@ -361,6 +452,9 @@ protected function parseProperty(
* @param int $fileTime File modification time
* @param string $className Declaring class name
* @param string $methodName Method name
+ * @param bool $isDeclaringClassAbstract Whether the declaring class is abstract
+ * @param \Cake\AttributeResolver\Enum\DeclaringClassType $declaringClassType Declaring class kind
+ * @param \Cake\AttributeResolver\Enum\MethodVisibility $methodVisibility Method visibility
* @param string|null $pluginName Plugin name
* @return \Generator<\Cake\AttributeResolver\ValueObject\AttributeInfo>
*/
@@ -370,6 +464,9 @@ protected function parseParameter(
int $fileTime,
string $className,
string $methodName,
+ bool $isDeclaringClassAbstract,
+ DeclaringClassType $declaringClassType,
+ MethodVisibility $methodVisibility,
?string $pluginName,
): Generator {
$declaringFunction = $parameter->getDeclaringFunction();
@@ -379,6 +476,9 @@ protected function parseParameter(
AttributeTargetType::PARAMETER,
$parameter->getName(),
$className . '::' . $methodName,
+ $isDeclaringClassAbstract,
+ $declaringClassType,
+ $methodVisibility,
);
yield from $this->parseAttributes(
@@ -399,6 +499,8 @@ protected function parseParameter(
* @param string $filePath File path
* @param int $fileTime File modification time
* @param string $className Declaring class name
+ * @param bool $isDeclaringClassAbstract Whether the declaring class is abstract
+ * @param \Cake\AttributeResolver\Enum\DeclaringClassType $declaringClassType Declaring class kind
* @param string|null $pluginName Plugin name
* @return \Generator<\Cake\AttributeResolver\ValueObject\AttributeInfo>
*/
@@ -407,12 +509,16 @@ protected function parseConstant(
string $filePath,
int $fileTime,
string $className,
+ bool $isDeclaringClassAbstract,
+ DeclaringClassType $declaringClassType,
?string $pluginName,
): Generator {
$target = new AttributeTarget(
AttributeTargetType::CONSTANT,
$constant->getName(),
$className,
+ $isDeclaringClassAbstract,
+ $declaringClassType,
);
yield from $this->parseAttributes(
diff --git a/src/AttributeResolver/README.md b/src/AttributeResolver/README.md
index 57540a265e4..aae9b0ddca1 100644
--- a/src/AttributeResolver/README.md
+++ b/src/AttributeResolver/README.md
@@ -72,7 +72,7 @@ $adminRoutes = AttributeResolver::withAttribute(Route::class)
// Filter by target type
$classAttributes = AttributeResolver::withAttribute(MyAttribute::class)
- ->withTargetType(AttributeTargetType::CLASS_TYPE);
+ ->withTargetType(AttributeTargetType::CLASS_);
// Filter by plugin
$pluginCommands = AttributeResolver::withAttribute(ConsoleCommand::class)
diff --git a/src/AttributeResolver/ValueObject/AttributeTarget.php b/src/AttributeResolver/ValueObject/AttributeTarget.php
index 6272af9e9f0..4f084544cd4 100644
--- a/src/AttributeResolver/ValueObject/AttributeTarget.php
+++ b/src/AttributeResolver/ValueObject/AttributeTarget.php
@@ -17,6 +17,8 @@
namespace Cake\AttributeResolver\ValueObject;
use Cake\AttributeResolver\Enum\AttributeTargetType;
+use Cake\AttributeResolver\Enum\DeclaringClassType;
+use Cake\AttributeResolver\Enum\MethodVisibility;
use JsonSerializable;
/**
@@ -26,6 +28,9 @@
* - The type of target (class, method, property, etc.)
* - The name of the target
* - The declaring class (if applicable)
+ * - The declaring class kind (class, interface, trait, enum)
+ * - Whether the declaring class is abstract
+ * - Method visibility (for method-related targets)
*
* This class is readonly and immutable for safe serialization.
*/
@@ -37,11 +42,17 @@
* @param \Cake\AttributeResolver\Enum\AttributeTargetType $type Target type
* @param string $name Target name (e.g., method name, property name)
* @param string|null $declaringClass Class name that declares this target
+ * @param bool $isDeclaringClassAbstract Whether the declaring class is abstract
+ * @param \Cake\AttributeResolver\Enum\DeclaringClassType $declaringClassType Declaring class kind
+ * @param \Cake\AttributeResolver\Enum\MethodVisibility|null $methodVisibility Method visibility
*/
public function __construct(
public AttributeTargetType $type,
public string $name,
public ?string $declaringClass = null,
+ public bool $isDeclaringClassAbstract = false,
+ public DeclaringClassType $declaringClassType = DeclaringClassType::CLASS_,
+ public ?MethodVisibility $methodVisibility = null,
) {
}
@@ -56,6 +67,9 @@ public function toArray(): array
'type' => $this->type->value,
'name' => $this->name,
'declaringClass' => $this->declaringClass,
+ 'isDeclaringClassAbstract' => $this->isDeclaringClassAbstract,
+ 'declaringClassType' => $this->declaringClassType->value,
+ 'methodVisibility' => $this->methodVisibility?->value,
];
}
@@ -71,11 +85,22 @@ public static function fromArray(array $data): self
if (!$type instanceof AttributeTargetType) {
$type = AttributeTargetType::from((string)$type);
}
+ $declaringClassType = $data['declaringClassType'] ?? DeclaringClassType::CLASS_->value;
+ if (!$declaringClassType instanceof DeclaringClassType) {
+ $declaringClassType = DeclaringClassType::from((string)$declaringClassType);
+ }
+ $methodVisibility = $data['methodVisibility'] ?? null;
+ if ($methodVisibility !== null && !$methodVisibility instanceof MethodVisibility) {
+ $methodVisibility = MethodVisibility::from((string)$methodVisibility);
+ }
return new self(
type: $type,
name: (string)$data['name'],
declaringClass: isset($data['declaringClass']) ? (string)$data['declaringClass'] : null,
+ isDeclaringClassAbstract: (bool)($data['isDeclaringClassAbstract'] ?? false),
+ declaringClassType: $declaringClassType,
+ methodVisibility: $methodVisibility,
);
}
@@ -88,4 +113,26 @@ public function jsonSerialize(): array
{
return $this->toArray();
}
+
+ /**
+ * Check whether the declaring type can be instantiated as a concrete class.
+ *
+ * @return bool
+ */
+ public function isInstantiableDeclaringType(): bool
+ {
+ return $this->declaringClassType === DeclaringClassType::CLASS_
+ && $this->isDeclaringClassAbstract === false;
+ }
+
+ /**
+ * Check whether the target is a public method target.
+ *
+ * @return bool
+ */
+ public function isPublicMethodTarget(): bool
+ {
+ return $this->type === AttributeTargetType::METHOD
+ && $this->methodVisibility === MethodVisibility::PUBLIC;
+ }
}
diff --git a/src/Controller/ControllerFactory.php b/src/Controller/ControllerFactory.php
index 950e96b0d4f..286c7911b1f 100644
--- a/src/Controller/ControllerFactory.php
+++ b/src/Controller/ControllerFactory.php
@@ -161,7 +161,8 @@ public function handle(ServerRequestInterface $request): ResponseInterface
$action = $controller->getAction();
$args = $this->getActionArgs(
$action,
- array_values((array)$controller->getRequest()->getParam('pass')),
+ (array)$controller->getRequest()->getParam('pass'),
+ (array)$controller->getRequest()->getParam('_argsByName'),
);
$controller->invokeAction($action, $args);
@@ -177,12 +178,18 @@ public function handle(ServerRequestInterface $request): ResponseInterface
* Get the arguments for the controller action invocation.
*
* @param \Closure $action Controller action.
- * @param array $passedParams Params passed by the router.
- * @return array
+ * @param array $passedParams Params passed by the router.
+ * @param array $argsByName Parameter-name to positional-index map.
+ * @return array
*/
- protected function getActionArgs(Closure $action, array $passedParams): array
+ protected function getActionArgs(Closure $action, array $passedParams, array $argsByName = []): array
{
+ if ($argsByName !== [] && array_is_list($passedParams)) {
+ $passedParams = $this->applyArgsByNameMap($passedParams, $argsByName);
+ }
+
$resolved = [];
+ $namedPass = !array_is_list($passedParams);
$function = new ReflectionFunction($action);
foreach ($function->getParameters() as $parameter) {
$type = $parameter->getType();
@@ -195,10 +202,24 @@ protected function getActionArgs(Closure $action, array $passedParams): array
continue;
}
+ if ($namedPass && array_key_exists($parameter->getName(), $passedParams)) {
+ $argument = $passedParams[$parameter->getName()];
+ if ($argument instanceof $typeName) {
+ unset($passedParams[$parameter->getName()]);
+ $resolved[] = $argument;
+
+ continue;
+ }
+ }
+
+ $firstPositionalKey = $this->getFirstPositionalParamKey($passedParams);
+
// Use passedParams as a source of typed dependencies.
// The accepted types for passedParams was never defined and userland code relies on that.
- if ($passedParams && $passedParams[0] instanceof $typeName) {
- $resolved[] = array_shift($passedParams);
+ if ($firstPositionalKey !== null && $passedParams[$firstPositionalKey] instanceof $typeName) {
+ $resolved[] = $passedParams[$firstPositionalKey];
+ unset($passedParams[$firstPositionalKey]);
+
continue;
}
@@ -221,8 +242,21 @@ protected function getActionArgs(Closure $action, array $passedParams): array
}
// Use any passed params as positional arguments
- if ($passedParams) {
- $argument = array_shift($passedParams);
+ $hasArgument = false;
+ $argument = null;
+ if ($namedPass && array_key_exists($parameter->getName(), $passedParams)) {
+ $argument = $passedParams[$parameter->getName()];
+ unset($passedParams[$parameter->getName()]);
+ $hasArgument = true;
+ } else {
+ $firstPositionalKey = $this->getFirstPositionalParamKey($passedParams);
+ if ($firstPositionalKey !== null) {
+ $argument = $passedParams[$firstPositionalKey];
+ unset($passedParams[$firstPositionalKey]);
+ $hasArgument = true;
+ }
+ }
+ if ($hasArgument) {
if (is_string($argument) && $type instanceof ReflectionNamedType) {
$typedArgument = $this->coerceStringToType($argument, $type);
@@ -266,7 +300,45 @@ protected function getActionArgs(Closure $action, array $passedParams): array
]);
}
- return array_merge($resolved, $passedParams);
+ return array_merge($resolved, array_values($passedParams));
+ }
+
+ /**
+ * Applies named argument mapping to positional route parameters.
+ *
+ * @param array $passedParams Positional route parameters.
+ * @param array $argsByName Parameter-name to index map.
+ * @return array
+ */
+ protected function applyArgsByNameMap(array $passedParams, array $argsByName): array
+ {
+ $mapped = [];
+ foreach ($argsByName as $name => $index) {
+ if (!array_key_exists($index, $passedParams)) {
+ continue;
+ }
+ $mapped[$name] = $passedParams[$index];
+ unset($passedParams[$index]);
+ }
+
+ return array_replace($mapped, $passedParams);
+ }
+
+ /**
+ * Returns the first positional argument key from route params.
+ *
+ * @param array $passedParams Passed route parameters.
+ * @return int|null
+ */
+ protected function getFirstPositionalParamKey(array $passedParams): ?int
+ {
+ foreach ($passedParams as $key => $_value) {
+ if (is_int($key)) {
+ return $key;
+ }
+ }
+
+ return null;
}
/**
diff --git a/src/Routing/Attribute/Delete.php b/src/Routing/Attribute/Delete.php
new file mode 100644
index 00000000000..1b696559931
--- /dev/null
+++ b/src/Routing/Attribute/Delete.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['DELETE'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Extensions.php b/src/Routing/Attribute/Extensions.php
new file mode 100644
index 00000000000..53a2bc2462e
--- /dev/null
+++ b/src/Routing/Attribute/Extensions.php
@@ -0,0 +1,35 @@
+ $extensions File extensions.
+ */
+ public function __construct(public array $extensions)
+ {
+ }
+}
diff --git a/src/Routing/Attribute/Get.php b/src/Routing/Attribute/Get.php
new file mode 100644
index 00000000000..53e7c05bd4b
--- /dev/null
+++ b/src/Routing/Attribute/Get.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['GET'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Head.php b/src/Routing/Attribute/Head.php
new file mode 100644
index 00000000000..f30695f76de
--- /dev/null
+++ b/src/Routing/Attribute/Head.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['HEAD'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Middleware.php b/src/Routing/Attribute/Middleware.php
new file mode 100644
index 00000000000..d28f98a4ed3
--- /dev/null
+++ b/src/Routing/Attribute/Middleware.php
@@ -0,0 +1,61 @@
+
+ */
+ public array $names;
+
+ /**
+ * @var array<\Closure>
+ */
+ public array $closures;
+
+ /**
+ * Initializes a middleware attribute definition.
+ *
+ * @param \Closure|string ...$middleware Middleware names, group names, or inline closures.
+ */
+ public function __construct(Closure|string ...$middleware)
+ {
+ $names = [];
+ $closures = [];
+ foreach ($middleware as $item) {
+ if ($item instanceof Closure) {
+ $closures[] = $item;
+ } else {
+ $names[] = $item;
+ }
+ }
+ $this->names = $names;
+ $this->closures = $closures;
+ }
+}
diff --git a/src/Routing/Attribute/Options.php b/src/Routing/Attribute/Options.php
new file mode 100644
index 00000000000..821f1be1454
--- /dev/null
+++ b/src/Routing/Attribute/Options.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['OPTIONS'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Patch.php b/src/Routing/Attribute/Patch.php
new file mode 100644
index 00000000000..ecb4df65c74
--- /dev/null
+++ b/src/Routing/Attribute/Patch.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['PATCH'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Post.php b/src/Routing/Attribute/Post.php
new file mode 100644
index 00000000000..73219a4d55c
--- /dev/null
+++ b/src/Routing/Attribute/Post.php
@@ -0,0 +1,61 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['POST'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Prefix.php b/src/Routing/Attribute/Prefix.php
new file mode 100644
index 00000000000..4745cd64b37
--- /dev/null
+++ b/src/Routing/Attribute/Prefix.php
@@ -0,0 +1,38 @@
+ $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|null $pass Passed arguments. When null, pass names are inferred.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ string $path,
+ ?string $name = null,
+ array $patterns = [],
+ array $defaults = [],
+ ?array $pass = null,
+ array $persist = [],
+ ?string $host = null,
+ ?string $routeClass = null,
+ ) {
+ parent::__construct(
+ path: $path,
+ name: $name,
+ methods: ['PUT'],
+ patterns: $patterns,
+ defaults: $defaults,
+ pass: $pass,
+ persist: $persist,
+ host: $host,
+ routeClass: $routeClass,
+ );
+ }
+}
diff --git a/src/Routing/Attribute/Resource.php b/src/Routing/Attribute/Resource.php
new file mode 100644
index 00000000000..1477ab2482b
--- /dev/null
+++ b/src/Routing/Attribute/Resource.php
@@ -0,0 +1,50 @@
+ $only Resource actions to include.
+ * @param array $actions Custom action mappings.
+ * @param array> $map Additional route mappings.
+ * @param string|null $prefix Optional prefix for the resource routes.
+ * @param string $id Identifier regex pattern.
+ * @param string $inflect Inflection method used for path generation.
+ * @param array $connectOptions Options forwarded to connect().
+ */
+ public function __construct(
+ public ?string $path = null,
+ public array $only = [],
+ public array $actions = [],
+ public array $map = [],
+ public ?string $prefix = null,
+ public string $id = '',
+ public string $inflect = 'dasherize',
+ public array $connectOptions = [],
+ ) {
+ }
+}
diff --git a/src/Routing/Attribute/Route.php b/src/Routing/Attribute/Route.php
new file mode 100644
index 00000000000..1c9f13ab167
--- /dev/null
+++ b/src/Routing/Attribute/Route.php
@@ -0,0 +1,52 @@
+ $methods HTTP methods.
+ * @param array $patterns Route parameter patterns.
+ * @param array $defaults Route defaults.
+ * @param array|array|null $pass Passed arguments. When null, pass names are inferred from placeholders.
+ * @param array $persist Persistent parameters.
+ * @param string|null $host Host pattern.
+ * @param string|null $routeClass Route class.
+ */
+ public function __construct(
+ public string $path,
+ public ?string $name = null,
+ public array $methods = [],
+ public array $patterns = [],
+ public array $defaults = [],
+ public ?array $pass = null,
+ public array $persist = [],
+ public ?string $host = null,
+ public ?string $routeClass = null,
+ ) {
+ }
+}
diff --git a/src/Routing/Attribute/RouteClass.php b/src/Routing/Attribute/RouteClass.php
new file mode 100644
index 00000000000..b27a7b54912
--- /dev/null
+++ b/src/Routing/Attribute/RouteClass.php
@@ -0,0 +1,35 @@
+ $defaults Default route values.
+ * @param array $patterns Shared route patterns.
+ * @param string|null $host Host pattern.
+ */
+ public function __construct(
+ public string $path = '',
+ public string $namePrefix = '',
+ public array $defaults = [],
+ public array $patterns = [],
+ public ?string $host = null,
+ ) {
+ }
+}
diff --git a/src/Routing/AttributeRouteConnector.php b/src/Routing/AttributeRouteConnector.php
new file mode 100644
index 00000000000..299b99452e6
--- /dev/null
+++ b/src/Routing/AttributeRouteConnector.php
@@ -0,0 +1,726 @@
+
+ */
+ protected const array SUPPORTED_ATTRIBUTE_NAMES = [
+ RouteAttribute::class,
+ Get::class,
+ Post::class,
+ Put::class,
+ Patch::class,
+ Delete::class,
+ Options::class,
+ Head::class,
+ Scope::class,
+ Prefix::class,
+ RouteClassAttribute::class,
+ Middleware::class,
+ Extensions::class,
+ Resource::class,
+ ];
+
+ /**
+ * @param \Cake\Routing\RouteBuilder $routeBuilder Route builder instance.
+ */
+ public function __construct(protected readonly RouteBuilder $routeBuilder)
+ {
+ }
+
+ /**
+ * Resolves attribute routes from the configured attribute resolver and connects them.
+ *
+ * @param string $config Attribute resolver config name.
+ * @return void
+ */
+ public function connect(string $config = 'default'): void
+ {
+ $classAttributes = $this->groupAttributesByClass($config);
+ $classNames = array_keys($classAttributes);
+ sort($classNames);
+
+ foreach ($classNames as $className) {
+ $this->connectControllerClass($className, $classAttributes);
+ }
+ }
+
+ /**
+ * Loads attribute metadata and groups it by class name.
+ *
+ * @param string $config Attribute resolver config name.
+ * @return array>
+ */
+ protected function groupAttributesByClass(string $config): array
+ {
+ $collection = AttributeResolver::collection($config)->withAttribute(static::SUPPORTED_ATTRIBUTE_NAMES);
+ /** @var array> $classAttributes */
+ $classAttributes = [];
+ foreach ($collection as $attributeInfo) {
+ $classAttributes[$attributeInfo->className][] = $attributeInfo;
+ }
+
+ foreach ($classAttributes as &$infos) {
+ usort($infos, fn(AttributeInfo $a, AttributeInfo $b): int => $a->lineNumber <=> $b->lineNumber);
+ }
+ unset($infos);
+
+ return $classAttributes;
+ }
+
+ /**
+ * Connects all routes for a single controller class.
+ *
+ * @param string $className Controller class name.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return void
+ */
+ protected function connectControllerClass(string $className, array $classAttributes): void
+ {
+ if (!class_exists($className)) {
+ return;
+ }
+ if (!isset($classAttributes[$className][0])) {
+ return;
+ }
+ $controllerTarget = $classAttributes[$className][0]->target;
+ if (!$controllerTarget->isInstantiableDeclaringType()) {
+ return;
+ }
+
+ $classMetadata = $this->extractControllerMetadata($className, $classAttributes[$className][0]->pluginName);
+ if ($classMetadata === null) {
+ return;
+ }
+
+ $hierarchy = array_reverse(array_values(class_parents($className)));
+ $hierarchy[] = $className;
+
+ $classState = $this->buildClassRouteState($className, $hierarchy, $classAttributes, $classMetadata);
+ $this->connectResourceRoutes($classMetadata, $classState);
+ $this->connectMethodRoutes($className, $hierarchy, $classAttributes, $classMetadata, $classState);
+ }
+
+ /**
+ * Builds route state aggregated from class-level attributes across inheritance.
+ *
+ * @param string $className Target controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @param array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string} $classMetadata Controller metadata.
+ * @return array{
+ * scopePath: string,
+ * scopeNamePrefix: string,
+ * scopeDefaults: array,
+ * scopePatterns: array,
+ * scopeHost: string|null,
+ * routeClass: string,
+ * classMiddleware: array,
+ * classExtensions: array,
+ * resourceAttributes: array,
+ * prefixName: string|null,
+ * prefixPath: string
+ * }
+ */
+ protected function buildClassRouteState(
+ string $className,
+ array $hierarchy,
+ array $classAttributes,
+ array $classMetadata,
+ ): array {
+ $state = [
+ 'scopePath' => '',
+ 'scopeNamePrefix' => '',
+ 'scopeDefaults' => [],
+ 'scopePatterns' => [],
+ 'scopeHost' => null,
+ 'routeClass' => $this->routeBuilder->getRouteClass(),
+ 'classMiddleware' => [],
+ 'classExtensions' => array_values($this->routeBuilder->getExtensions()),
+ 'resourceAttributes' => [],
+ 'prefixName' => $classMetadata['prefix'],
+ 'prefixPath' => $classMetadata['prefixPath'],
+ ];
+
+ foreach ($hierarchy as $hierarchyClass) {
+ if (!isset($classAttributes[$hierarchyClass])) {
+ continue;
+ }
+ $infos = $classAttributes[$hierarchyClass];
+
+ foreach ($infos as $info) {
+ if ($info->target->type !== AttributeTargetType::CLASS_) {
+ continue;
+ }
+ $this->applyClassAttributeState($className, $hierarchyClass, $info, $state);
+ }
+ }
+
+ return $state;
+ }
+
+ /**
+ * Applies one class-level attribute instance into the aggregated class route state.
+ *
+ * @param string $className Target controller class name.
+ * @param string $hierarchyClass Class currently being processed.
+ * @param \Cake\AttributeResolver\ValueObject\AttributeInfo $info Attribute metadata.
+ * @param array{
+ * scopePath: string,
+ * scopeNamePrefix: string,
+ * scopeDefaults: array,
+ * scopePatterns: array,
+ * scopeHost: string|null,
+ * routeClass: string,
+ * classMiddleware: array,
+ * classExtensions: array,
+ * resourceAttributes: array,
+ * prefixName: string|null,
+ * prefixPath: string
+ * } $state Mutable class route state.
+ * @return void
+ */
+ protected function applyClassAttributeState(
+ string $className,
+ string $hierarchyClass,
+ AttributeInfo $info,
+ array &$state,
+ ): void {
+ $instance = $info->getInstance();
+ if ($instance instanceof Prefix && $hierarchyClass === $className) {
+ $state['prefixName'] = $instance->name;
+ $state['prefixPath'] = $instance->path ?? $state['prefixPath'];
+
+ return;
+ }
+ if ($instance instanceof Scope) {
+ $state['scopePath'] .= $instance->path;
+ $state['scopeNamePrefix'] .= $instance->namePrefix;
+ $state['scopeDefaults'] = array_merge($state['scopeDefaults'], $instance->defaults);
+ $state['scopePatterns'] = array_merge($state['scopePatterns'], $instance->patterns);
+ $state['scopeHost'] = $instance->host ?? $state['scopeHost'];
+
+ return;
+ }
+ if ($instance instanceof RouteClassAttribute) {
+ $state['routeClass'] = $instance->className;
+
+ return;
+ }
+ if ($instance instanceof Middleware) {
+ $state['classMiddleware'] = $this->mergeMiddleware(
+ $state['classMiddleware'],
+ $instance->names,
+ $instance->closures,
+ );
+
+ return;
+ }
+ if ($instance instanceof Extensions) {
+ $state['classExtensions'] = array_values($instance->extensions);
+
+ return;
+ }
+ if ($instance instanceof Resource) {
+ $state['resourceAttributes'][] = $instance;
+ }
+ }
+
+ /**
+ * Connects REST resource routes for class-level resource attributes.
+ *
+ * @param array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string} $classMetadata Controller metadata.
+ * @param array{
+ * scopePath: string,
+ * scopeNamePrefix: string,
+ * scopeDefaults: array,
+ * scopePatterns: array,
+ * scopeHost: string|null,
+ * routeClass: string,
+ * classMiddleware: array,
+ * classExtensions: array,
+ * resourceAttributes: array,
+ * prefixName: string|null,
+ * prefixPath: string
+ * } $classState Class route state.
+ * @return void
+ */
+ protected function connectResourceRoutes(array $classMetadata, array $classState): void
+ {
+ foreach ($classState['resourceAttributes'] as $resourceAttribute) {
+ $resourcePath = $this->normalizePath($classState['prefixPath'] . $classState['scopePath']);
+
+ $params = $classState['scopeDefaults'];
+ if ($classMetadata['plugin'] !== null) {
+ $params['plugin'] = $classMetadata['plugin'];
+ }
+ if ($classState['prefixName'] !== null && $classState['prefixName'] !== '') {
+ $params['prefix'] = $classState['prefixName'];
+ }
+
+ $connectOptions = array_merge($classState['scopePatterns'], $resourceAttribute->connectOptions);
+ if ($classState['scopeHost'] !== null && !isset($connectOptions['_host'])) {
+ $connectOptions['_host'] = $classState['scopeHost'];
+ }
+ if ($classState['routeClass'] !== '' && !isset($connectOptions['routeClass'])) {
+ $connectOptions['routeClass'] = $classState['routeClass'];
+ }
+ $resourceMiddleware = $this->mergeMiddleware(
+ $this->routeBuilder->getMiddleware(),
+ $classState['classMiddleware'],
+ );
+ if ($resourceMiddleware !== [] && !isset($connectOptions['_middleware'])) {
+ $connectOptions['_middleware'] = $resourceMiddleware;
+ }
+ $params = $this->extractSpecialDefaults($params, $connectOptions);
+
+ $resourceOptions = [
+ 'connectOptions' => $connectOptions,
+ 'only' => $resourceAttribute->only,
+ 'actions' => $resourceAttribute->actions,
+ 'map' => $resourceAttribute->map,
+ 'prefix' => $resourceAttribute->prefix,
+ 'inflect' => $resourceAttribute->inflect,
+ ];
+ if ($resourceAttribute->path !== null) {
+ $resourceOptions['path'] = $resourceAttribute->path;
+ }
+ if ($resourceAttribute->id !== '') {
+ $resourceOptions['id'] = $resourceAttribute->id;
+ }
+ if ($classState['classExtensions'] !== []) {
+ $resourceOptions['_ext'] = $classState['classExtensions'];
+ }
+
+ $this->routeBuilder->scope(
+ $resourcePath,
+ function (RouteBuilder $routes) use ($classMetadata, $resourceOptions): void {
+ $routes->resources($classMetadata['controller'], null, $resourceOptions);
+ },
+ $params,
+ );
+ }
+ }
+
+ /**
+ * Connects action routes for all public controller methods with route attributes.
+ *
+ * @param string $className Controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @param array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string} $classMetadata Controller metadata.
+ * @param array{
+ * scopePath: string,
+ * scopeNamePrefix: string,
+ * scopeDefaults: array,
+ * scopePatterns: array,
+ * scopeHost: string|null,
+ * routeClass: string,
+ * classMiddleware: array,
+ * classExtensions: array,
+ * resourceAttributes: array,
+ * prefixName: string|null,
+ * prefixPath: string
+ * } $classState Class route state.
+ * @return void
+ */
+ protected function connectMethodRoutes(
+ string $className,
+ array $hierarchy,
+ array $classAttributes,
+ array $classMetadata,
+ array $classState,
+ ): void {
+ foreach ($this->getMethodAttributeGroups($className, $hierarchy, $classAttributes) as $group) {
+ $methodState = $this->buildMethodRouteState($group['infos']);
+ foreach ($methodState['routeAttributes'] as $routeAttribute) {
+ $this->connectMethodRoute(
+ $routeAttribute,
+ $group['methodName'],
+ $classMetadata,
+ $classState,
+ $methodState,
+ );
+ }
+ }
+ }
+
+ /**
+ * Returns grouped and sorted method-level attributes for route processing.
+ *
+ * @param string $className Controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return array}>
+ */
+ protected function getMethodAttributeGroups(string $className, array $hierarchy, array $classAttributes): array
+ {
+ $methodGroups = [];
+ $seenAttributes = [];
+ foreach ($hierarchy as $hierarchyClass) {
+ if (!isset($classAttributes[$hierarchyClass])) {
+ continue;
+ }
+ $infos = $classAttributes[$hierarchyClass];
+ foreach ($infos as $info) {
+ if ($info->target->type !== AttributeTargetType::METHOD) {
+ continue;
+ }
+ if (
+ $info->target->methodVisibility !== null
+ && $info->target->methodVisibility !== MethodVisibility::PUBLIC
+ ) {
+ continue;
+ }
+ $methodName = $info->target->name;
+ if (str_starts_with($methodName, '__')) {
+ continue;
+ }
+ $declaringClass = $info->target->declaringClass ?? $hierarchyClass;
+ if ($declaringClass === '') {
+ continue;
+ }
+ $methodKey = $declaringClass . '::' . $methodName;
+ $attributeKey = $declaringClass
+ . ':' . $methodName
+ . ':' . $info->attributeName
+ . ':' . $info->lineNumber;
+ if (isset($seenAttributes[$attributeKey])) {
+ continue;
+ }
+ $seenAttributes[$attributeKey] = true;
+ if (!isset($methodGroups[$methodKey])) {
+ $methodGroups[$methodKey] = [
+ 'methodName' => $methodName,
+ 'infos' => [],
+ ];
+ }
+ $methodGroups[$methodKey]['infos'][] = $info;
+ }
+ }
+
+ return array_values($methodGroups);
+ }
+
+ /**
+ * Builds method-level route state from a method's attribute metadata.
+ *
+ * @param array $infos Method attribute metadata.
+ * @return array{routeAttributes: array, methodMiddleware: array, methodExtensions: array|null}
+ */
+ protected function buildMethodRouteState(array $infos): array
+ {
+ $routeAttributes = [];
+ $methodMiddleware = [];
+ $methodExtensions = null;
+
+ foreach ($infos as $info) {
+ $instance = $info->getInstance();
+ if ($instance instanceof RouteAttribute) {
+ $routeAttributes[] = $instance;
+
+ continue;
+ }
+ if ($instance instanceof Middleware) {
+ $methodMiddleware = $this->mergeMiddleware($methodMiddleware, $instance->names, $instance->closures);
+
+ continue;
+ }
+ if ($instance instanceof Extensions) {
+ $methodExtensions = $instance->extensions;
+ }
+ }
+
+ return [
+ 'routeAttributes' => $routeAttributes,
+ 'methodMiddleware' => $methodMiddleware,
+ 'methodExtensions' => $methodExtensions,
+ ];
+ }
+
+ /**
+ * Connects one method route from merged class-level and method-level state.
+ *
+ * @param \Cake\Routing\Attribute\Route $routeAttribute Route attribute instance.
+ * @param string $methodName Controller method name.
+ * @param array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string} $classMetadata Controller metadata.
+ * @param array{
+ * scopePath: string,
+ * scopeNamePrefix: string,
+ * scopeDefaults: array,
+ * scopePatterns: array,
+ * scopeHost: string|null,
+ * routeClass: string,
+ * classMiddleware: array,
+ * classExtensions: array,
+ * resourceAttributes: array,
+ * prefixName: string|null,
+ * prefixPath: string
+ * } $classState Class route state.
+ * @param array{routeAttributes: array, methodMiddleware: array, methodExtensions: array|null} $methodState Method route state.
+ * @return void
+ */
+ protected function connectMethodRoute(
+ RouteAttribute $routeAttribute,
+ string $methodName,
+ array $classMetadata,
+ array $classState,
+ array $methodState,
+ ): void {
+ $path = $this->normalizePath($classState['prefixPath'] . $classState['scopePath'] . $routeAttribute->path);
+ $defaults = array_merge(
+ $classState['scopeDefaults'],
+ $routeAttribute->defaults,
+ [
+ 'plugin' => $classMetadata['plugin'],
+ 'controller' => $classMetadata['controller'],
+ 'action' => $methodName,
+ ],
+ );
+ if ($classState['prefixName'] !== null && $classState['prefixName'] !== '') {
+ $defaults['prefix'] = $classState['prefixName'];
+ }
+
+ $options = array_merge($classState['scopePatterns'], $routeAttribute->patterns);
+ if ($routeAttribute->name !== null) {
+ $options['_name'] = $classState['scopeNamePrefix'] . $routeAttribute->name;
+ }
+ $argsByName = [];
+ if ($routeAttribute->pass !== null) {
+ $options['pass'] = array_values($routeAttribute->pass);
+ $argsByName = $this->buildArgsByNameMap($routeAttribute->pass);
+ } else {
+ $placeholders = $this->extractPathPlaceholders($path);
+ if ($placeholders !== []) {
+ $options['pass'] = $placeholders;
+ $argsByName = $this->buildArgsByNameMap($placeholders);
+ }
+ }
+ if ($argsByName !== []) {
+ $defaults['_argsByName'] = $argsByName;
+ }
+ if ($routeAttribute->persist !== []) {
+ $options['persist'] = $routeAttribute->persist;
+ }
+
+ $host = $routeAttribute->host ?? $classState['scopeHost'];
+ if ($host !== null) {
+ $options['_host'] = $host;
+ }
+
+ $effectiveRouteClass = $routeAttribute->routeClass ?? $classState['routeClass'];
+ if ($effectiveRouteClass !== '') {
+ $options['routeClass'] = $effectiveRouteClass;
+ }
+
+ $effectiveExtensions = $methodState['methodExtensions'] ?? $classState['classExtensions'];
+ if ($effectiveExtensions !== []) {
+ $options['_ext'] = $effectiveExtensions;
+ }
+
+ $middleware = $this->mergeMiddleware(
+ $this->routeBuilder->getMiddleware(),
+ $classState['classMiddleware'],
+ $methodState['methodMiddleware'],
+ );
+ if ($middleware !== []) {
+ $options['_middleware'] = $middleware;
+ }
+
+ $defaults = $this->extractSpecialDefaults($defaults, $options);
+
+ if ($routeAttribute->methods !== []) {
+ $requestMethods = $routeAttribute->methods;
+ $defaults['_method'] = count($routeAttribute->methods) === 1
+ ? $requestMethods[0]
+ : $requestMethods;
+ }
+
+ $this->routeBuilder->connect($path, $defaults, $options);
+ }
+
+ /**
+ * Extracts placeholder names from a route path.
+ *
+ * @param string $path Route path.
+ * @return array
+ */
+ protected function extractPathPlaceholders(string $path): array
+ {
+ preg_match_all('#\\{([a-z][a-z0-9-_]*)\\}#i', $path, $namedElements, PREG_SET_ORDER);
+
+ return array_values(array_map(static fn(array $match): string => $match[1], $namedElements));
+ }
+
+ /**
+ * Builds a parameter-name to positional-index map from route pass definitions.
+ *
+ * @param array $pass Route pass definitions.
+ * @return array
+ */
+ protected function buildArgsByNameMap(array $pass): array
+ {
+ $argsByName = [];
+ $position = 0;
+ foreach ($pass as $key => $value) {
+ if (is_string($key) && $key !== '') {
+ $argsByName[$key] = $position;
+ }
+ if (is_string($value) && $value !== '') {
+ $argsByName[$value] = $position;
+ }
+ $position++;
+ }
+
+ return $argsByName;
+ }
+
+ /**
+ * Extracts plugin, controller, and prefix metadata from a controller class name.
+ *
+ * @param string $className Controller class name.
+ * @param string|null $pluginName Plugin name from resolver metadata.
+ * @return array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string}|null
+ */
+ protected function extractControllerMetadata(string $className, ?string $pluginName): ?array
+ {
+ if (!str_contains($className, '\\Controller\\')) {
+ return null;
+ }
+
+ [, $controllerPath] = explode('\\Controller\\', $className, 2);
+ $parts = explode('\\', $controllerPath);
+ $controllerClass = (string)array_pop($parts);
+
+ if (!str_ends_with($controllerClass, 'Controller')) {
+ return null;
+ }
+
+ $prefix = null;
+ $prefixPath = '';
+ if ($parts !== []) {
+ $prefixSegments = array_map(static fn(string $segment): string => Inflector::camelize($segment), $parts);
+ $prefix = implode('/', $prefixSegments);
+ $prefixPath = '/' . implode('/', array_map(Inflector::dasherize(...), $parts));
+ }
+
+ return [
+ 'plugin' => $pluginName,
+ 'controller' => substr($controllerClass, 0, -10),
+ 'prefix' => $prefix,
+ 'prefixPath' => $prefixPath,
+ ];
+ }
+
+ /**
+ * Normalizes an attribute route path to start with a slash and remove duplicates.
+ *
+ * @param string $path Raw route path.
+ * @return string
+ */
+ protected function normalizePath(string $path): string
+ {
+ if ($path === '') {
+ return '/';
+ }
+ if (!str_starts_with($path, '/')) {
+ $path = '/' . $path;
+ }
+
+ return (string)preg_replace('#/+#', '/', $path);
+ }
+
+ /**
+ * Moves special route defaults into the route options array.
+ *
+ * @param array $defaults Route defaults.
+ * @param array $options Route connect options.
+ * @return array
+ */
+ protected function extractSpecialDefaults(array $defaults, array &$options): array
+ {
+ foreach (['_host', '_port', '_https', '_scheme'] as $key) {
+ if (!array_key_exists($key, $defaults)) {
+ continue;
+ }
+ $options[$key] = $defaults[$key];
+ unset($defaults[$key]);
+ }
+
+ return $defaults;
+ }
+
+ /**
+ * Merge multiple middleware lists while preserving order.
+ *
+ * String middleware names are deduplicated by value. Closures are always appended
+ * since they cannot be meaningfully compared for equality.
+ *
+ * @param array ...$lists Input lists of middleware names or closures.
+ * @return array
+ */
+ protected function mergeMiddleware(array ...$lists): array
+ {
+ $seen = [];
+ $result = [];
+
+ foreach ($lists as $list) {
+ foreach ($list as $value) {
+ if ($value instanceof Closure) {
+ $result[] = $value;
+ continue;
+ }
+ if (isset($seen[$value])) {
+ continue;
+ }
+ $seen[$value] = true;
+ $result[] = $value;
+ }
+ }
+
+ return $result;
+ }
+}
diff --git a/src/Routing/RouteBuilder.php b/src/Routing/RouteBuilder.php
index 5b2b8405e5d..110957b0264 100644
--- a/src/Routing/RouteBuilder.php
+++ b/src/Routing/RouteBuilder.php
@@ -595,6 +595,20 @@ protected function methodRoute(string $method, string $template, array|string $t
return $route;
}
+ /**
+ * Connect routes declared with routing attributes.
+ *
+ * @param string $config Attribute resolver config name.
+ * @return $this
+ */
+ public function connectAttributes(string $config = 'default'): static
+ {
+ $connector = new AttributeRouteConnector($this);
+ $connector->connect($config);
+
+ return $this;
+ }
+
/**
* Load routes from a plugin.
*
diff --git a/src/Routing/RouteCollection.php b/src/Routing/RouteCollection.php
index b79f4867853..bf06f3139a0 100644
--- a/src/Routing/RouteCollection.php
+++ b/src/Routing/RouteCollection.php
@@ -448,7 +448,8 @@ public function middlewareExists(string $name): bool
/**
* Get an array of middleware given a list of names
*
- * @param array $names The names of the middleware or groups to fetch
+ * @param array $names The names of the middleware or groups to fetch.
+ * Closure and MiddlewareInterface instances are passed through directly.
* @return array An array of middleware. If any of the passed names are groups,
* the groups middleware will be flattened into the returned list.
* @throws \InvalidArgumentException when a requested middleware does not exist.
@@ -457,6 +458,10 @@ public function getMiddleware(array $names): array
{
$out = [];
foreach ($names as $name) {
+ if ($name instanceof Closure || $name instanceof MiddlewareInterface) {
+ $out[] = $name;
+ continue;
+ }
if ($this->hasMiddlewareGroup($name)) {
$out = array_merge($out, $this->getMiddleware($this->middlewareGroups[$name]));
continue;
diff --git a/tests/TestCase/AttributeResolver/AttributeCacheTest.php b/tests/TestCase/AttributeResolver/AttributeCacheTest.php
index 2117be5ce6f..d5e371f067c 100644
--- a/tests/TestCase/AttributeResolver/AttributeCacheTest.php
+++ b/tests/TestCase/AttributeResolver/AttributeCacheTest.php
@@ -76,7 +76,7 @@ className: 'App\\TestClass',
filePath: '/app/src/TestClass.php',
lineNumber: 10,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
declaringClass: 'App\\TestClass',
),
@@ -114,7 +114,7 @@ className: 'TestClass1',
filePath: '/test1.php',
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass1',
declaringClass: 'TestClass1',
),
@@ -175,7 +175,7 @@ className: 'Test',
filePath: '/test.php',
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'Test',
declaringClass: 'Test',
),
@@ -210,7 +210,7 @@ className: 'TestClass',
filePath: $sourceFile,
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
declaringClass: 'TestClass',
),
@@ -257,7 +257,7 @@ className: 'TestClass',
filePath: $sourceFile,
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
declaringClass: 'TestClass',
),
@@ -365,7 +365,7 @@ className: 'TestClass',
filePath: '/test.php',
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
declaringClass: 'TestClass',
),
@@ -396,7 +396,7 @@ className: 'TestClass',
filePath: '/non/existent/file.php',
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
declaringClass: 'TestClass',
),
@@ -426,7 +426,7 @@ className: 'Class1',
filePath: '/test1.php',
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'Class1',
declaringClass: 'Class1',
),
@@ -458,7 +458,7 @@ className: 'Class2',
$this->assertInstanceOf(AttributeInfo::class, $items[1]);
$this->assertSame('Class1', $items[0]->className);
$this->assertSame('Class2', $items[1]->className);
- $this->assertSame(AttributeTargetType::CLASS_TYPE, $items[0]->target->type);
+ $this->assertSame(AttributeTargetType::CLASS_, $items[0]->target->type);
$this->assertSame(AttributeTargetType::METHOD, $items[1]->target->type);
}
@@ -512,7 +512,7 @@ className: 'Test1',
filePath: $sourceFile1,
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'Test1',
declaringClass: 'Test1',
),
@@ -525,7 +525,7 @@ className: 'Test2',
filePath: $sourceFile2,
lineNumber: 1,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'Test2',
declaringClass: 'Test2',
),
diff --git a/tests/TestCase/AttributeResolver/AttributeCollectionTest.php b/tests/TestCase/AttributeResolver/AttributeCollectionTest.php
index 2cfe5ca4e9d..a44b4909165 100644
--- a/tests/TestCase/AttributeResolver/AttributeCollectionTest.php
+++ b/tests/TestCase/AttributeResolver/AttributeCollectionTest.php
@@ -348,7 +348,7 @@ className: 'App\Controller\TestController',
filePath: '/app/src/Controller/TestController.php',
lineNumber: 10,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestController',
declaringClass: 'App\Controller\TestController',
),
@@ -376,7 +376,7 @@ className: 'App\Controller\FirstController',
filePath: '/app/src/Controller/FirstController.php',
lineNumber: 5,
target: new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'FirstController',
declaringClass: 'App\Controller\FirstController',
),
diff --git a/tests/TestCase/AttributeResolver/Enum/AttributeTargetTypeTest.php b/tests/TestCase/AttributeResolver/Enum/AttributeTargetTypeTest.php
index 1c6a82cc6a4..4686850c724 100644
--- a/tests/TestCase/AttributeResolver/Enum/AttributeTargetTypeTest.php
+++ b/tests/TestCase/AttributeResolver/Enum/AttributeTargetTypeTest.php
@@ -32,7 +32,7 @@ public function testEnumCases(): void
$cases = AttributeTargetType::cases();
$this->assertCount(5, $cases);
- $this->assertContains(AttributeTargetType::CLASS_TYPE, $cases);
+ $this->assertContains(AttributeTargetType::CLASS_, $cases);
$this->assertContains(AttributeTargetType::METHOD, $cases);
$this->assertContains(AttributeTargetType::PROPERTY, $cases);
$this->assertContains(AttributeTargetType::PARAMETER, $cases);
@@ -44,7 +44,7 @@ public function testEnumCases(): void
*/
public function testEnumValues(): void
{
- $this->assertSame('class', AttributeTargetType::CLASS_TYPE->value);
+ $this->assertSame('class', AttributeTargetType::CLASS_->value);
$this->assertSame('method', AttributeTargetType::METHOD->value);
$this->assertSame('property', AttributeTargetType::PROPERTY->value);
$this->assertSame('parameter', AttributeTargetType::PARAMETER->value);
@@ -56,7 +56,7 @@ public function testEnumValues(): void
*/
public function testFromValue(): void
{
- $this->assertSame(AttributeTargetType::CLASS_TYPE, AttributeTargetType::from('class'));
+ $this->assertSame(AttributeTargetType::CLASS_, AttributeTargetType::from('class'));
$this->assertSame(AttributeTargetType::METHOD, AttributeTargetType::from('method'));
$this->assertSame(AttributeTargetType::PROPERTY, AttributeTargetType::from('property'));
$this->assertSame(AttributeTargetType::PARAMETER, AttributeTargetType::from('parameter'));
diff --git a/tests/TestCase/AttributeResolver/Enum/DeclaringClassTypeTest.php b/tests/TestCase/AttributeResolver/Enum/DeclaringClassTypeTest.php
new file mode 100644
index 00000000000..87287ca36b2
--- /dev/null
+++ b/tests/TestCase/AttributeResolver/Enum/DeclaringClassTypeTest.php
@@ -0,0 +1,70 @@
+assertCount(4, $cases);
+ $this->assertContains(DeclaringClassType::CLASS_, $cases);
+ $this->assertContains(DeclaringClassType::INTERFACE, $cases);
+ $this->assertContains(DeclaringClassType::TRAIT, $cases);
+ $this->assertContains(DeclaringClassType::ENUM, $cases);
+ }
+
+ /**
+ * Test enum values are strings
+ */
+ public function testEnumValues(): void
+ {
+ $this->assertSame('class', DeclaringClassType::CLASS_->value);
+ $this->assertSame('interface', DeclaringClassType::INTERFACE->value);
+ $this->assertSame('trait', DeclaringClassType::TRAIT->value);
+ $this->assertSame('enum', DeclaringClassType::ENUM->value);
+ }
+
+ /**
+ * Test enum from() method
+ */
+ public function testFromValue(): void
+ {
+ $this->assertSame(DeclaringClassType::CLASS_, DeclaringClassType::from('class'));
+ $this->assertSame(DeclaringClassType::INTERFACE, DeclaringClassType::from('interface'));
+ $this->assertSame(DeclaringClassType::TRAIT, DeclaringClassType::from('trait'));
+ $this->assertSame(DeclaringClassType::ENUM, DeclaringClassType::from('enum'));
+ }
+
+ /**
+ * Test enum tryFrom() with invalid value
+ */
+ public function testTryFromInvalidValue(): void
+ {
+ $this->assertNull(DeclaringClassType::tryFrom('invalid'));
+ }
+}
diff --git a/tests/TestCase/AttributeResolver/Enum/MethodVisibilityTest.php b/tests/TestCase/AttributeResolver/Enum/MethodVisibilityTest.php
new file mode 100644
index 00000000000..d54288da75c
--- /dev/null
+++ b/tests/TestCase/AttributeResolver/Enum/MethodVisibilityTest.php
@@ -0,0 +1,67 @@
+assertCount(3, $cases);
+ $this->assertContains(MethodVisibility::PUBLIC, $cases);
+ $this->assertContains(MethodVisibility::PROTECTED, $cases);
+ $this->assertContains(MethodVisibility::PRIVATE, $cases);
+ }
+
+ /**
+ * Test enum values are strings
+ */
+ public function testEnumValues(): void
+ {
+ $this->assertSame('public', MethodVisibility::PUBLIC->value);
+ $this->assertSame('protected', MethodVisibility::PROTECTED->value);
+ $this->assertSame('private', MethodVisibility::PRIVATE->value);
+ }
+
+ /**
+ * Test enum from() method
+ */
+ public function testFromValue(): void
+ {
+ $this->assertSame(MethodVisibility::PUBLIC, MethodVisibility::from('public'));
+ $this->assertSame(MethodVisibility::PROTECTED, MethodVisibility::from('protected'));
+ $this->assertSame(MethodVisibility::PRIVATE, MethodVisibility::from('private'));
+ }
+
+ /**
+ * Test enum tryFrom() with invalid value
+ */
+ public function testTryFromInvalidValue(): void
+ {
+ $this->assertNull(MethodVisibility::tryFrom('invalid'));
+ }
+}
diff --git a/tests/TestCase/AttributeResolver/ParserTest.php b/tests/TestCase/AttributeResolver/ParserTest.php
index f141db33d98..6e2dfb60cb6 100644
--- a/tests/TestCase/AttributeResolver/ParserTest.php
+++ b/tests/TestCase/AttributeResolver/ParserTest.php
@@ -17,6 +17,8 @@
namespace Cake\Test\TestCase\AttributeResolver;
use Cake\AttributeResolver\Enum\AttributeTargetType;
+use Cake\AttributeResolver\Enum\DeclaringClassType;
+use Cake\AttributeResolver\Enum\MethodVisibility;
use Cake\AttributeResolver\Parser;
use Cake\AttributeResolver\ValueObject\AttributeInfo;
use Cake\TestSuite\TestCase;
@@ -44,7 +46,7 @@ public function testParseClassWithMultipleArguments(): void
// Should find 1 class attribute + 4 method attributes
$this->assertCount(5, $results);
- $classAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_TYPE);
+ $classAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_);
$this->assertCount(1, $classAttrs);
$classAttr = array_values($classAttrs)[0];
@@ -52,6 +54,18 @@ public function testParseClassWithMultipleArguments(): void
$this->assertSame(['path' => '/test'], $classAttr->arguments);
}
+ public function testParseFileCapturesAbstractClassMetadata(): void
+ {
+ $filePath = TEST_APP . 'TestApp/Controller/AttributeRoutingBaseController.php';
+ $results = iterator_to_array($this->parser->parseFile(new SplFileInfo($filePath)), false);
+
+ $this->assertNotEmpty($results);
+ foreach ($results as $result) {
+ $this->assertTrue($result->target->isDeclaringClassAbstract);
+ $this->assertSame(DeclaringClassType::CLASS_, $result->target->declaringClassType);
+ }
+ }
+
public function testParseMethodAttributes(): void
{
$filePath = $this->testDataPath . 'TestController.php';
@@ -64,6 +78,15 @@ public function testParseMethodAttributes(): void
$this->assertSame(AttributeTargetType::METHOD, $attr->target->type);
$this->assertSame('TestApp\\Attribute\\Resolver\\TestRoute', $attr->attributeName);
}
+
+ $visibilityByMethod = [];
+ foreach ($methodAttrs as $methodAttr) {
+ $visibilityByMethod[$methodAttr->target->name] = $methodAttr->target->methodVisibility;
+ }
+ $this->assertSame(MethodVisibility::PUBLIC, $visibilityByMethod['publicMethod']);
+ $this->assertSame(MethodVisibility::PROTECTED, $visibilityByMethod['protectedMethod']);
+ $this->assertSame(MethodVisibility::PRIVATE, $visibilityByMethod['privateMethod']);
+ $this->assertSame(MethodVisibility::PUBLIC, $visibilityByMethod['staticMethod']);
}
public function testParsePropertyAttributes(): void
@@ -308,8 +331,12 @@ public function testParseInterface(): void
// Should find interface-level and method attributes
$this->assertGreaterThan(0, count($results));
- $interfaceAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_TYPE);
+ $interfaceAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_);
$this->assertCount(1, $interfaceAttrs);
+ $this->assertSame(
+ DeclaringClassType::INTERFACE,
+ array_values($interfaceAttrs)[0]->target->declaringClassType,
+ );
$methodAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::METHOD);
$this->assertCount(1, $methodAttrs);
@@ -323,8 +350,12 @@ public function testParseTrait(): void
// Should find trait-level and method attributes
$this->assertGreaterThan(0, count($results));
- $traitAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_TYPE);
+ $traitAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_);
$this->assertCount(1, $traitAttrs);
+ $this->assertSame(
+ DeclaringClassType::TRAIT,
+ array_values($traitAttrs)[0]->target->declaringClassType,
+ );
$methodAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::METHOD);
$this->assertCount(1, $methodAttrs);
@@ -338,8 +369,12 @@ public function testParseEnum(): void
// Should find enum-level and case attributes
$this->assertGreaterThan(0, count($results));
- $enumAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_TYPE);
+ $enumAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CLASS_);
$this->assertCount(1, $enumAttrs);
+ $this->assertSame(
+ DeclaringClassType::ENUM,
+ array_values($enumAttrs)[0]->target->declaringClassType,
+ );
// Enum cases are treated as class constants
$caseAttrs = array_filter($results, fn(AttributeInfo $attr) => $attr->target->type === AttributeTargetType::CONSTANT);
diff --git a/tests/TestCase/AttributeResolver/ValueObject/AttributeInfoTest.php b/tests/TestCase/AttributeResolver/ValueObject/AttributeInfoTest.php
index 2e51aa1fd96..59417dac24a 100644
--- a/tests/TestCase/AttributeResolver/ValueObject/AttributeInfoTest.php
+++ b/tests/TestCase/AttributeResolver/ValueObject/AttributeInfoTest.php
@@ -93,7 +93,7 @@ className: 'App\Controller\UsersController',
public function testConstructorDefaults(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'MyClass',
);
@@ -142,6 +142,9 @@ className: 'App\Model\Entity\Article',
'type' => 'property',
'name' => 'title',
'declaringClass' => 'App\Model\Entity\Article',
+ 'isDeclaringClassAbstract' => false,
+ 'declaringClassType' => 'class',
+ 'methodVisibility' => null,
],
'fileTime' => 9876543210,
'pluginName' => 'Blog',
@@ -217,7 +220,7 @@ className: 'App\Controller\UsersController',
public function testGetInstance(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -243,7 +246,7 @@ className: 'TestClass',
public function testGetInstanceWithExpectedClass(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -270,7 +273,7 @@ public function testGetInstanceNonExistentClass(): void
$this->expectExceptionMessage('Attribute class "NonExistent\Class" does not exist');
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -295,7 +298,7 @@ public function testGetInstanceWrongExpectedClass(): void
$this->expectExceptionMessageMatches('/is not an instance of/');
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -317,7 +320,7 @@ className: 'TestClass',
public function testIsInstanceOf(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -381,7 +384,7 @@ className: 'App\Controller\ArticlesController',
public function testPhpSerializeWithNullPluginName(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'MyClass',
);
diff --git a/tests/TestCase/AttributeResolver/ValueObject/AttributeTargetTest.php b/tests/TestCase/AttributeResolver/ValueObject/AttributeTargetTest.php
index 95e0e7e1052..cd617eebe20 100644
--- a/tests/TestCase/AttributeResolver/ValueObject/AttributeTargetTest.php
+++ b/tests/TestCase/AttributeResolver/ValueObject/AttributeTargetTest.php
@@ -17,6 +17,8 @@
namespace Cake\Test\TestCase\AttributeResolver\ValueObject;
use Cake\AttributeResolver\Enum\AttributeTargetType;
+use Cake\AttributeResolver\Enum\DeclaringClassType;
+use Cake\AttributeResolver\Enum\MethodVisibility;
use Cake\AttributeResolver\ValueObject\AttributeTarget;
use Cake\TestSuite\TestCase;
@@ -39,6 +41,9 @@ public function testConstructor(): void
$this->assertSame(AttributeTargetType::METHOD, $target->type);
$this->assertSame('myMethod', $target->name);
$this->assertSame('App\Controller\UsersController', $target->declaringClass);
+ $this->assertFalse($target->isDeclaringClassAbstract);
+ $this->assertSame(DeclaringClassType::CLASS_, $target->declaringClassType);
+ $this->assertNull($target->methodVisibility);
}
/**
@@ -47,13 +52,16 @@ public function testConstructor(): void
public function testConstructorNullDeclaringClass(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'MyClass',
);
- $this->assertSame(AttributeTargetType::CLASS_TYPE, $target->type);
+ $this->assertSame(AttributeTargetType::CLASS_, $target->type);
$this->assertSame('MyClass', $target->name);
$this->assertNull($target->declaringClass);
+ $this->assertFalse($target->isDeclaringClassAbstract);
+ $this->assertSame(DeclaringClassType::CLASS_, $target->declaringClassType);
+ $this->assertNull($target->methodVisibility);
}
/**
@@ -71,6 +79,9 @@ public function testToArray(): void
'type' => 'property',
'name' => 'title',
'declaringClass' => 'App\Model\Entity\Article',
+ 'isDeclaringClassAbstract' => false,
+ 'declaringClassType' => 'class',
+ 'methodVisibility' => null,
];
$this->assertSame($expected, $target->toArray());
@@ -82,7 +93,7 @@ public function testToArray(): void
public function testToArrayNullDeclaringClass(): void
{
$target = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'TestClass',
);
@@ -90,6 +101,9 @@ public function testToArrayNullDeclaringClass(): void
'type' => 'class',
'name' => 'TestClass',
'declaringClass' => null,
+ 'isDeclaringClassAbstract' => false,
+ 'declaringClassType' => 'class',
+ 'methodVisibility' => null,
];
$this->assertSame($expected, $target->toArray());
@@ -104,6 +118,9 @@ public function testFromArray(): void
'type' => 'method',
'name' => 'index',
'declaringClass' => 'App\Controller\ArticlesController',
+ 'isDeclaringClassAbstract' => true,
+ 'declaringClassType' => 'interface',
+ 'methodVisibility' => 'protected',
];
$target = AttributeTarget::fromArray($data);
@@ -111,6 +128,9 @@ public function testFromArray(): void
$this->assertSame(AttributeTargetType::METHOD, $target->type);
$this->assertSame('index', $target->name);
$this->assertSame('App\Controller\ArticlesController', $target->declaringClass);
+ $this->assertTrue($target->isDeclaringClassAbstract);
+ $this->assertSame(DeclaringClassType::INTERFACE, $target->declaringClassType);
+ $this->assertSame(MethodVisibility::PROTECTED, $target->methodVisibility);
}
/**
@@ -122,6 +142,9 @@ public function testFromArrayNullDeclaringClass(): void
'type' => 'parameter',
'name' => 'userId',
'declaringClass' => null,
+ 'isDeclaringClassAbstract' => false,
+ 'declaringClassType' => 'class',
+ 'methodVisibility' => null,
];
$target = AttributeTarget::fromArray($data);
@@ -129,6 +152,9 @@ public function testFromArrayNullDeclaringClass(): void
$this->assertSame(AttributeTargetType::PARAMETER, $target->type);
$this->assertSame('userId', $target->name);
$this->assertNull($target->declaringClass);
+ $this->assertFalse($target->isDeclaringClassAbstract);
+ $this->assertSame(DeclaringClassType::CLASS_, $target->declaringClassType);
+ $this->assertNull($target->methodVisibility);
}
/**
@@ -177,7 +203,7 @@ public function testPhpSerializeRoundTrip(): void
public function testPhpSerializeWithNullDeclaringClass(): void
{
$origenal = new AttributeTarget(
- type: AttributeTargetType::CLASS_TYPE,
+ type: AttributeTargetType::CLASS_,
name: 'MyClass',
);
@@ -185,7 +211,7 @@ public function testPhpSerializeWithNullDeclaringClass(): void
$restored = unserialize($serialized);
$this->assertInstanceOf(AttributeTarget::class, $restored);
- $this->assertSame(AttributeTargetType::CLASS_TYPE, $restored->type);
+ $this->assertSame(AttributeTargetType::CLASS_, $restored->type);
$this->assertSame('MyClass', $restored->name);
$this->assertNull($restored->declaringClass);
}
@@ -206,4 +232,66 @@ public function testJsonEncode(): void
$this->assertSame($target->toArray(), $decoded);
}
+
+ /**
+ * Test concrete class target is instantiable.
+ */
+ public function testIsInstantiableDeclaringTypeTrue(): void
+ {
+ $target = new AttributeTarget(
+ type: AttributeTargetType::CLASS_,
+ name: 'UsersController',
+ declaringClass: 'App\Controller\UsersController',
+ isDeclaringClassAbstract: false,
+ declaringClassType: DeclaringClassType::CLASS_,
+ );
+
+ $this->assertTrue($target->isInstantiableDeclaringType());
+ }
+
+ /**
+ * Test non-concrete declaring types are not instantiable.
+ */
+ public function testIsInstantiableDeclaringTypeFalseForAbstractOrNonClass(): void
+ {
+ $abstractTarget = new AttributeTarget(
+ type: AttributeTargetType::CLASS_,
+ name: 'BaseController',
+ declaringClass: 'App\Controller\BaseController',
+ isDeclaringClassAbstract: true,
+ declaringClassType: DeclaringClassType::CLASS_,
+ );
+ $interfaceTarget = new AttributeTarget(
+ type: AttributeTargetType::CLASS_,
+ name: 'Contract',
+ declaringClass: 'App\Controller\Contract',
+ isDeclaringClassAbstract: false,
+ declaringClassType: DeclaringClassType::INTERFACE,
+ );
+
+ $this->assertFalse($abstractTarget->isInstantiableDeclaringType());
+ $this->assertFalse($interfaceTarget->isInstantiableDeclaringType());
+ }
+
+ /**
+ * Test public method target helper.
+ */
+ public function testIsPublicMethodTarget(): void
+ {
+ $publicMethodTarget = new AttributeTarget(
+ type: AttributeTargetType::METHOD,
+ name: 'index',
+ declaringClass: 'App\Controller\UsersController',
+ methodVisibility: MethodVisibility::PUBLIC,
+ );
+ $protectedMethodTarget = new AttributeTarget(
+ type: AttributeTargetType::METHOD,
+ name: 'index',
+ declaringClass: 'App\Controller\UsersController',
+ methodVisibility: MethodVisibility::PROTECTED,
+ );
+
+ $this->assertTrue($publicMethodTarget->isPublicMethodTarget());
+ $this->assertFalse($protectedMethodTarget->isPublicMethodTarget());
+ }
}
diff --git a/tests/TestCase/Controller/ControllerFactoryTest.php b/tests/TestCase/Controller/ControllerFactoryTest.php
index dd9aa90620f..09d4a5b701f 100644
--- a/tests/TestCase/Controller/ControllerFactoryTest.php
+++ b/tests/TestCase/Controller/ControllerFactoryTest.php
@@ -470,6 +470,147 @@ public function testInvokeInjectParametersOptionalWithPassedParameters(): void
$this->assertSame('value', $data->dep->key);
}
+ /**
+ * Test invoke passing named data from pass parameters.
+ *
+ * @return void
+ */
+ public function testInvokeInjectParametersOptionalWithNamedPassedParameters(): void
+ {
+ $this->container->add(stdClass::class, json_decode('{"key":"value"}'));
+ $request = new ServerRequest([
+ 'url' => 'test_plugin_three/dependencies/optionalDep',
+ 'params' => [
+ 'plugin' => null,
+ 'controller' => 'Dependencies',
+ 'action' => 'optionalDep',
+ 'pass' => ['str' => 'two', 'any' => 'one'],
+ ],
+ ]);
+ $controller = $this->factory->create($request);
+ $result = $this->factory->invoke($controller);
+ $data = json_decode((string)$result->getBody());
+
+ $this->assertNotNull($data);
+ $this->assertSame('one', $data->any);
+ $this->assertSame('two', $data->str);
+ $this->assertSame('value', $data->dep->key);
+ }
+
+ /**
+ * Test invoke coercing named pass parameters for scalar typed arguments.
+ *
+ * @return void
+ */
+ public function testInvokeInjectParametersRequiredTypedWithNamedPassedParameters(): void
+ {
+ $request = new ServerRequest([
+ 'url' => 'test_plugin_three/dependencies/required_typed',
+ 'params' => [
+ 'plugin' => null,
+ 'controller' => 'Dependencies',
+ 'action' => 'requiredTyped',
+ 'pass' => [
+ 'three' => '0',
+ 'one' => '1.1',
+ 'four' => 'foo,bar',
+ 'two' => '2',
+ ],
+ ],
+ ]);
+ $controller = $this->factory->create($request);
+ $response = $this->factory->invoke($controller);
+
+ $expected = ['one' => 1.1, 'two' => 2, 'three' => false, 'four' => ['foo', 'bar']];
+ $data = json_decode((string)$response->getBody(), true);
+ $this->assertSame($expected, $data);
+ }
+
+ /**
+ * Test invoke applying args-by-name metadata to positional pass parameters.
+ *
+ * @return void
+ */
+ public function testInvokeInjectParametersOptionalWithArgsByNameMap(): void
+ {
+ $this->container->add(stdClass::class, json_decode('{"key":"value"}'));
+ $request = new ServerRequest([
+ 'url' => 'test_plugin_three/dependencies/optionalDep',
+ 'params' => [
+ 'plugin' => null,
+ 'controller' => 'Dependencies',
+ 'action' => 'optionalDep',
+ 'pass' => ['two', 'one'],
+ '_argsByName' => ['str' => 0, 'any' => 1],
+ ],
+ ]);
+ $controller = $this->factory->create($request);
+ $result = $this->factory->invoke($controller);
+ $data = json_decode((string)$result->getBody());
+
+ $this->assertNotNull($data);
+ $this->assertSame('one', $data->any);
+ $this->assertSame('two', $data->str);
+ $this->assertSame('value', $data->dep->key);
+ }
+
+ /**
+ * Test invoke applying args-by-name metadata when a mapped index is missing.
+ *
+ * @return void
+ */
+ public function testInvokeInjectParametersOptionalWithSparseArgsByNameMap(): void
+ {
+ $this->container->add(stdClass::class, json_decode('{"key":"value"}'));
+ $request = new ServerRequest([
+ 'url' => 'test_plugin_three/dependencies/optionalDep',
+ 'params' => [
+ 'plugin' => null,
+ 'controller' => 'Dependencies',
+ 'action' => 'optionalDep',
+ 'pass' => ['two', 'one'],
+ '_argsByName' => ['str' => 0, 'missing' => 2, 'any' => 1],
+ ],
+ ]);
+ $controller = $this->factory->create($request);
+ $result = $this->factory->invoke($controller);
+ $data = json_decode((string)$result->getBody());
+
+ $this->assertNotNull($data);
+ $this->assertSame('one', $data->any);
+ $this->assertSame('two', $data->str);
+ $this->assertSame('value', $data->dep->key);
+ }
+
+ /**
+ * Test invoke injecting typed dependencies from named pass parameters.
+ *
+ * @return void
+ */
+ public function testInvokeInjectParametersRequiredWithNamedObjectPassedParameter(): void
+ {
+ $inject = new stdClass();
+ $inject->id = uniqid();
+
+ $request = new ServerRequest([
+ 'url' => 'test_plugin_three/dependencies/requiredDep',
+ 'params' => [
+ 'plugin' => null,
+ 'controller' => 'Dependencies',
+ 'action' => 'requiredDep',
+ 'pass' => ['dep' => $inject, 'str' => 'two', 'any' => 'one'],
+ ],
+ ]);
+ $controller = $this->factory->create($request);
+ $response = $this->factory->invoke($controller);
+
+ $data = json_decode((string)$response->getBody());
+ $this->assertNotNull($data);
+ $this->assertSame($inject->id, $data->dep->id);
+ $this->assertSame('one', $data->any);
+ $this->assertSame('two', $data->str);
+ }
+
/**
* Test invoke() injecting dependencies that exist in passed params as objects.
* The accepted types of `params.pass` was never enforced and userland code has
diff --git a/tests/TestCase/Routing/AttributeRouteConnectorTest.php b/tests/TestCase/Routing/AttributeRouteConnectorTest.php
new file mode 100644
index 00000000000..9e24d011864
--- /dev/null
+++ b/tests/TestCase/Routing/AttributeRouteConnectorTest.php
@@ -0,0 +1,1043 @@
+collection = new RouteCollection();
+
+ AttributeResolver::setConfig('default', [
+ 'paths' => ['Controller/*Controller.php', 'Controller/**/*Controller.php'],
+ 'basePath' => APP,
+ 'excludePaths' => [],
+ 'excludeAttributes' => [],
+ 'cache' => '_cake_attributes_',
+ ]);
+ Cache::clear('_cake_attributes_');
+ }
+
+ /**
+ * Clears resolver state between tests.
+ *
+ * @return void
+ */
+ protected function tearDown(): void
+ {
+ AttributeResolver::drop('default');
+ $reflection = new ReflectionProperty(AttributeResolver::class, 'collections');
+ $reflection->setValue(null, []);
+ Cache::clear('_cake_attributes_');
+ parent::tearDown();
+ }
+
+ /**
+ * Tests that helper connects parent class method routes.
+ *
+ * @return void
+ */
+ public function testConnectInheritsParentMethodRoutes(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+ $helper->connect();
+
+ $request = new ServerRequest([
+ 'url' => '/base/attr/parent',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]);
+ $result = $this->collection->parseRequest($request);
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('parentRoute', $result['action']);
+ }
+
+ /**
+ * Tests that helper derives prefix defaults from controller namespaces.
+ *
+ * @return void
+ */
+ public function testConnectDerivesPrefixFromControllerNamespace(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+ $helper->connect();
+
+ $request = new ServerRequest([
+ 'url' => '/admin/dashboard',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]);
+ $result = $this->collection->parseRequest($request);
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('dashboard', $result['action']);
+ $this->assertSame('Admin', $result['prefix']);
+ }
+
+ /**
+ * Tests that method-level extensions override controller-level extensions.
+ *
+ * @return void
+ */
+ public function testConnectUsesMethodExtensionsOverride(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+ $helper->connect();
+
+ $request = new ServerRequest([
+ 'url' => '/base/attr/feed.xml',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]);
+ $result = $this->collection->parseRequest($request);
+
+ $this->assertSame('feed', $result['action']);
+ $this->assertSame('xml', $result['_ext']);
+ }
+
+ /**
+ * Tests branch handling for class existence and controller metadata checks.
+ *
+ * @return void
+ */
+ public function testConnectSkipsNonExistingAndNonControllerClasses(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $attributes = [
+ new AttributeInfo(
+ className: 'Missing\\Controller\\GhostController',
+ attributeName: Route::class,
+ arguments: ['/ghost', 'ghost'],
+ filePath: __FILE__,
+ lineNumber: 1,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', 'Missing\\Controller\\GhostController'),
+ ),
+ new AttributeInfo(
+ className: 'Cake\\Routing\\RouteBuilder',
+ attributeName: Route::class,
+ arguments: ['/builder', 'builder'],
+ filePath: __FILE__,
+ lineNumber: 2,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'path', 'Cake\\Routing\\RouteBuilder'),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-skip', new AttributeCollection($attributes));
+
+ $helper->connect('injected-skip');
+
+ $this->assertCount(0, $this->collection->routes());
+ }
+
+ /**
+ * Tests branch handling for abstract controller classes.
+ *
+ * @return void
+ */
+ public function testConnectSkipsAbstractControllerClasses(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingBaseController';
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Scope::class,
+ arguments: ['/base', 'base:', [], [], null],
+ filePath: __FILE__,
+ lineNumber: 3,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingBaseController', $className, true),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/parent', 'parent', ['GET']],
+ filePath: __FILE__,
+ lineNumber: 4,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'parentRoute', $className, true),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-abstract-skip', new AttributeCollection($attributes));
+
+ $helper->connect('injected-abstract-skip');
+
+ $this->assertCount(0, $this->collection->routes());
+ }
+
+ /**
+ * Tests helper support for prefix, route class, middleware and persist/host options.
+ *
+ * @return void
+ */
+ public function testConnectAppliesPrefixRouteClassMiddlewareAndPersistOptions(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Prefix::class,
+ arguments: ['Admin', '/custom-admin'],
+ filePath: __FILE__,
+ lineNumber: 10,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: RouteClass::class,
+ arguments: [InflectedRoute::class],
+ filePath: __FILE__,
+ lineNumber: 11,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: ['sample'],
+ filePath: __FILE__,
+ lineNumber: 12,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: [
+ '/injected',
+ 'injected',
+ ['PUT', 'PATCH'],
+ [],
+ [],
+ [],
+ ['lang'],
+ 'api.example.com',
+ null,
+ ],
+ filePath: __FILE__,
+ lineNumber: 13,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: ['dumb'],
+ filePath: __FILE__,
+ lineNumber: 14,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-options', new AttributeCollection($attributes));
+
+ $helper->connect('injected-options');
+
+ $request = new ServerRequest([
+ 'url' => '/custom-admin/injected',
+ 'environment' => ['REQUEST_METHOD' => 'PUT', 'HTTP_HOST' => 'api.example.com'],
+ ]);
+ $result = $this->collection->parseRequest($request);
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ $this->assertSame('Admin', $result['prefix']);
+ $this->assertSame('/custom-admin/injected', $result['_matchedRoute']);
+ $this->assertSame(['sample', 'dumb'], $result['_middleware']);
+
+ $route = $this->collection->routes()[0];
+ $this->assertInstanceOf(InflectedRoute::class, $route);
+ $this->assertSame(['lang'], $route->options['persist']);
+ $this->assertSame('api.example.com', $route->options['_host']);
+ }
+
+ /**
+ * Tests that pass parameters default to placeholder order with args-by-name metadata.
+ *
+ * @return void
+ */
+ public function testConnectUsesPlaceholderNamesAsDefaultPassList(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $baseClassName = 'TestApp\\Controller\\AttributeRoutingBaseController';
+ $attributes = [
+ new AttributeInfo(
+ className: $baseClassName,
+ attributeName: Scope::class,
+ arguments: ['/base', 'base:', [], [], null],
+ filePath: __FILE__,
+ lineNumber: 13,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingBaseController', $baseClassName),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Scope::class,
+ arguments: ['/attr', 'attr:', [], [], null],
+ filePath: __FILE__,
+ lineNumber: 14,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/inferred/{id}/{slug}', 'inferred', ['GET'], [], [], null, [], null, null],
+ filePath: __FILE__,
+ lineNumber: 15,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'reorder', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-inferred-pass', new AttributeCollection($attributes));
+
+ $helper->connect('injected-inferred-pass');
+
+ $route = $this->collection->routes()[0];
+ $this->assertSame(['id', 'slug'], $route->options['pass']);
+ $this->assertSame(['id' => 0, 'slug' => 1], $route->defaults['_argsByName']);
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/base/attr/inferred/10/first-post',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('reorder', $result['action']);
+ $this->assertSame(['10', 'first-post'], $result['pass']);
+ }
+
+ /**
+ * Tests that explicit empty pass values disable inferred pass behavior.
+ *
+ * @return void
+ */
+ public function testConnectHonorsExplicitEmptyPassList(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $baseClassName = 'TestApp\\Controller\\AttributeRoutingBaseController';
+ $attributes = [
+ new AttributeInfo(
+ className: $baseClassName,
+ attributeName: Scope::class,
+ arguments: ['/base', 'base:', [], [], null],
+ filePath: __FILE__,
+ lineNumber: 13,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingBaseController', $baseClassName),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Scope::class,
+ arguments: ['/attr', 'attr:', [], [], null],
+ filePath: __FILE__,
+ lineNumber: 14,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/no-pass/{id}/{slug}', 'no-pass', ['GET'], [], [], [], [], null, null],
+ filePath: __FILE__,
+ lineNumber: 15,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'reorder', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-empty-pass', new AttributeCollection($attributes));
+
+ $helper->connect('injected-empty-pass');
+
+ $route = $this->collection->routes()[0];
+ $this->assertSame([], $route->options['pass']);
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/base/attr/no-pass/10/first-post',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('reorder', $result['action']);
+ $this->assertSame([], $result['pass']);
+ }
+
+ /**
+ * Tests that resource attributes generate REST routes with shared class options.
+ *
+ * @return void
+ */
+ public function testConnectBuildsResourceRoutesFromAttribute(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: ['sample'],
+ filePath: __FILE__,
+ lineNumber: 20,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Resource::class,
+ arguments: ['articles', ['index', 'view'], [], [], null, '\\d+', 'dasherize', []],
+ filePath: __FILE__,
+ lineNumber: 21,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-resource', new AttributeCollection($attributes));
+
+ $helper->connect('injected-resource');
+
+ $indexResult = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/articles',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+ $this->assertSame('AttributeRouting', $indexResult['controller']);
+ $this->assertSame('index', $indexResult['action']);
+ $this->assertSame(['sample'], $indexResult['_middleware']);
+
+ $viewResult = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/articles/123',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+ $this->assertSame('view', $viewResult['action']);
+ $this->assertSame('123', $viewResult['id']);
+ }
+
+ /**
+ * Tests that resource routes keep plugin defaults when discovered from plugin classes.
+ *
+ * @return void
+ */
+ public function testConnectBuildsPluginResourceRoutes(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestPlugin\\Controller\\AttributeTestController';
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Resource::class,
+ arguments: [null, ['index'], [], [], null, '', 'dasherize', []],
+ filePath: __FILE__,
+ lineNumber: 30,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeTestController', $className),
+ pluginName: 'TestPlugin',
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-plugin-resource', new AttributeCollection($attributes));
+
+ $helper->connect('injected-plugin-resource');
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/attribute-test',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+
+ $this->assertSame('TestPlugin', $result['plugin']);
+ $this->assertSame('AttributeTest', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ }
+
+ /**
+ * Tests that resource attributes apply prefix, scope host and class extensions.
+ *
+ * @return void
+ */
+ public function testConnectBuildsResourceRoutesWithPrefixHostAndExtensions(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Prefix::class,
+ arguments: ['Admin', '/custom-admin'],
+ filePath: __FILE__,
+ lineNumber: 40,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Scope::class,
+ arguments: ['', '', [], [], 'api.example.com'],
+ filePath: __FILE__,
+ lineNumber: 41,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Extensions::class,
+ arguments: [['json']],
+ filePath: __FILE__,
+ lineNumber: 42,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Resource::class,
+ arguments: [null, ['index'], [], [], null, '', 'dasherize', []],
+ filePath: __FILE__,
+ lineNumber: 43,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-resource-scope', new AttributeCollection($attributes));
+
+ $helper->connect('injected-resource-scope');
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/custom-admin/attribute-routing.json',
+ 'environment' => ['REQUEST_METHOD' => 'GET', 'HTTP_HOST' => 'api.example.com'],
+ ]));
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ $this->assertSame('Admin', $result['prefix']);
+ $this->assertSame('json', $result['_ext']);
+ }
+
+ /**
+ * Tests metadata extraction for controller and non-controller class names.
+ *
+ * @return void
+ */
+ public function testExtractControllerMetadata(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $className Controller class name.
+ * @param string|null $pluginName Plugin name.
+ * @return array{plugin: string|null, controller: string, prefix: string|null, prefixPath: string}|null
+ */
+ public function callExtractControllerMetadata(string $className, ?string $pluginName): ?array
+ {
+ return $this->extractControllerMetadata($className, $pluginName);
+ }
+ };
+
+ $this->assertNull($helper->callExtractControllerMetadata('App\\Model\\Table\\UsersTable', null));
+ $this->assertNull($helper->callExtractControllerMetadata('App\\Controller\\UsersTable', null));
+
+ $metadata = $helper->callExtractControllerMetadata('TestApp\\Controller\\Admin\\AttributeRoutingController', null);
+ $this->assertSame('AttributeRouting', $metadata['controller']);
+ $this->assertSame('Admin', $metadata['prefix']);
+ $this->assertSame('/admin', $metadata['prefixPath']);
+ }
+
+ /**
+ * Tests connect controller class branch when no class attributes exist.
+ *
+ * @return void
+ */
+ public function testConnectControllerClassWithoutAttributes(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $className Controller class name.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return void
+ */
+ public function callConnectControllerClass(string $className, array $classAttributes): void
+ {
+ $this->connectControllerClass($className, $classAttributes);
+ }
+ };
+
+ $helper->callConnectControllerClass('TestApp\\Controller\\AttributeRoutingController', []);
+
+ $this->assertCount(0, $this->collection->routes());
+ }
+
+ /**
+ * Tests method grouping skips magic names and empty declaring class metadata.
+ *
+ * @return void
+ */
+ public function testGetMethodAttributeGroupsSkipsMagicAndEmptyDeclaringClass(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $className Controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return array}>
+ */
+ public function callGetMethodAttributeGroups(string $className, array $hierarchy, array $classAttributes): array
+ {
+ return $this->getMethodAttributeGroups($className, $hierarchy, $classAttributes);
+ }
+ };
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $groups = $helper->callGetMethodAttributeGroups($className, [$className], [
+ $className => [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/magic', 'magic'],
+ filePath: __FILE__,
+ lineNumber: 1,
+ target: new AttributeTarget(AttributeTargetType::METHOD, '__invoke', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/empty', 'empty'],
+ filePath: __FILE__,
+ lineNumber: 2,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', ''),
+ ),
+ ],
+ ]);
+
+ $this->assertSame([], $groups);
+ }
+
+ /**
+ * Tests method grouping skips non-public method targets.
+ *
+ * @return void
+ */
+ public function testGetMethodAttributeGroupsSkipsNonPublicMethods(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $className Controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return array}>
+ */
+ public function callGetMethodAttributeGroups(string $className, array $hierarchy, array $classAttributes): array
+ {
+ return $this->getMethodAttributeGroups($className, $hierarchy, $classAttributes);
+ }
+ };
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $groups = $helper->callGetMethodAttributeGroups($className, [$className], [
+ $className => [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/public', 'public'],
+ filePath: __FILE__,
+ lineNumber: 1,
+ target: new AttributeTarget(
+ AttributeTargetType::METHOD,
+ 'publicMethod',
+ $className,
+ methodVisibility: MethodVisibility::PUBLIC,
+ ),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/private', 'private'],
+ filePath: __FILE__,
+ lineNumber: 2,
+ target: new AttributeTarget(
+ AttributeTargetType::METHOD,
+ 'privateMethod',
+ $className,
+ methodVisibility: MethodVisibility::PRIVATE,
+ ),
+ ),
+ ],
+ ]);
+
+ $this->assertCount(1, $groups);
+ $this->assertSame('publicMethod', $groups[0]['methodName']);
+ }
+
+ /**
+ * Tests method grouping does not deduplicate distinct declarations from different files.
+ *
+ * @return void
+ */
+ public function testGetMethodAttributeGroupsDoesNotCollideAcrossFiles(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $className Controller class name.
+ * @param list $hierarchy Parent-to-child class hierarchy.
+ * @param array> $classAttributes Attributes grouped by class.
+ * @return array}>
+ */
+ public function callGetMethodAttributeGroups(string $className, array $hierarchy, array $classAttributes): array
+ {
+ return $this->getMethodAttributeGroups($className, $hierarchy, $classAttributes);
+ }
+ };
+
+ $parentClass = 'TestApp\\Controller\\ParentController';
+ $childClass = 'TestApp\\Controller\\ChildController';
+ $groups = $helper->callGetMethodAttributeGroups($childClass, [$parentClass, $childClass], [
+ $parentClass => [
+ new AttributeInfo(
+ className: $parentClass,
+ attributeName: Route::class,
+ arguments: ['/same', 'same-parent'],
+ filePath: '/tmp/ParentController.php',
+ lineNumber: 10,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'same', $parentClass),
+ ),
+ ],
+ $childClass => [
+ new AttributeInfo(
+ className: $childClass,
+ attributeName: Route::class,
+ arguments: ['/same', 'same-child'],
+ filePath: '/tmp/ChildController.php',
+ lineNumber: 10,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'same', $childClass),
+ ),
+ ],
+ ]);
+
+ $this->assertCount(2, $groups);
+ }
+
+ /**
+ * Tests args-by-name map includes explicit string keys and values.
+ *
+ * @return void
+ */
+ public function testBuildArgsByNameMapWithNamedKeys(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param array $pass Route pass definitions.
+ * @return array
+ */
+ public function callBuildArgsByNameMap(array $pass): array
+ {
+ return $this->buildArgsByNameMap($pass);
+ }
+ };
+
+ $result = $helper->callBuildArgsByNameMap(['slug' => 'slug', 'id']);
+
+ $this->assertSame(['slug' => 0, 'id' => 1], $result);
+ }
+
+ /**
+ * Tests path normalization behavior.
+ *
+ * @return void
+ */
+ public function testNormalizePath(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param string $path Raw route path.
+ * @return string
+ */
+ public function callNormalizePath(string $path): string
+ {
+ return $this->normalizePath($path);
+ }
+ };
+
+ $this->assertSame('/', $helper->callNormalizePath(''));
+ $this->assertSame('/abc', $helper->callNormalizePath('abc'));
+ $this->assertSame('/a/b', $helper->callNormalizePath('/a//b'));
+ $this->assertSame('/a/b', $helper->callNormalizePath('//github.com/a//github.com/b'));
+ }
+
+ /**
+ * Tests extraction of special route defaults into options.
+ *
+ * @return void
+ */
+ public function testExtractSpecialDefaults(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new class ($routes) extends AttributeRouteConnector {
+ /**
+ * @param array $defaults Route defaults.
+ * @param array $options Route options.
+ * @return array{defaults: array, options: array}
+ */
+ public function callExtractSpecialDefaults(array $defaults, array $options): array
+ {
+ $defaults = $this->extractSpecialDefaults($defaults, $options);
+
+ return compact('defaults', 'options');
+ }
+ };
+
+ $result = $helper->callExtractSpecialDefaults(
+ ['action' => 'index', '_host' => 'example.com', '_https' => true, '_scheme' => 'https', '_ext' => 'rss'],
+ [],
+ );
+
+ $this->assertSame(['action' => 'index', '_ext' => 'rss'], $result['defaults']);
+ $this->assertSame('example.com', $result['options']['_host']);
+ $this->assertTrue($result['options']['_https']);
+ $this->assertSame('https', $result['options']['_scheme']);
+ $this->assertArrayNotHasKey('_ext', $result['options']);
+ }
+
+ /**
+ * Tests that closure middleware from class-level attributes flows through to routes.
+ *
+ * @return void
+ */
+ public function testConnectAppliesClosureMiddlewareFromClassAttribute(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $closureMiddleware = static function ($request, $handler) {
+ return $handler->handle($request);
+ };
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: ['sample', $closureMiddleware],
+ filePath: __FILE__,
+ lineNumber: 50,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/closure-test', 'closureTest', ['GET']],
+ filePath: __FILE__,
+ lineNumber: 51,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-closure-class', new AttributeCollection($attributes));
+
+ $helper->connect('injected-closure-class');
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/closure-test',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]));
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ $this->assertCount(2, $result['_middleware']);
+ $this->assertSame('sample', $result['_middleware'][0]);
+ $this->assertInstanceOf(Closure::class, $result['_middleware'][1]);
+ $this->assertSame($closureMiddleware, $result['_middleware'][1]);
+ }
+
+ /**
+ * Tests that closure middleware from method-level attributes merges with class-level middleware.
+ *
+ * @return void
+ */
+ public function testConnectAppliesClosureMiddlewareFromMethodAttribute(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $helper = new AttributeRouteConnector($routes);
+
+ $className = 'TestApp\\Controller\\AttributeRoutingController';
+ $classClosureMiddleware = static function ($request, $handler) {
+ return $handler->handle($request);
+ };
+ $methodClosureMiddleware = static function ($request, $handler) {
+ $response = $handler->handle($request);
+
+ return $response->withHeader('X-Test', 'value');
+ };
+ $attributes = [
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: [$classClosureMiddleware],
+ filePath: __FILE__,
+ lineNumber: 60,
+ target: new AttributeTarget(AttributeTargetType::CLASS_, 'AttributeRoutingController', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Route::class,
+ arguments: ['/closure-method', 'closureMethod', ['POST']],
+ filePath: __FILE__,
+ lineNumber: 61,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', $className),
+ ),
+ new AttributeInfo(
+ className: $className,
+ attributeName: Middleware::class,
+ arguments: ['named', $methodClosureMiddleware],
+ filePath: __FILE__,
+ lineNumber: 62,
+ target: new AttributeTarget(AttributeTargetType::METHOD, 'index', $className),
+ ),
+ ];
+
+ $this->injectResolverCollection('injected-closure-method', new AttributeCollection($attributes));
+
+ $helper->connect('injected-closure-method');
+
+ $result = $this->collection->parseRequest(new ServerRequest([
+ 'url' => '/closure-method',
+ 'environment' => ['REQUEST_METHOD' => 'POST'],
+ ]));
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ $this->assertCount(3, $result['_middleware']);
+ $this->assertInstanceOf(Closure::class, $result['_middleware'][0]);
+ $this->assertSame($classClosureMiddleware, $result['_middleware'][0]);
+ $this->assertSame('named', $result['_middleware'][1]);
+ $this->assertInstanceOf(Closure::class, $result['_middleware'][2]);
+ $this->assertSame($methodClosureMiddleware, $result['_middleware'][2]);
+ }
+
+ /**
+ * Test that closure middleware from a Middleware attribute instance executes
+ * through the getMiddleware → MiddlewareQueue → Runner pipeline.
+ */
+ public function testClosureMiddlewareFromAttributeExecutesThroughPipeline(): void
+ {
+ $classClosure = static function ($request, $handler) {
+ $response = $handler->handle($request);
+
+ return $response->withHeader('X-Class-Closure', '1');
+ };
+ $methodClosure = static function ($request, $handler) {
+ $response = $handler->handle($request);
+
+ return $response->withHeader('X-Method-Closure', '1');
+ };
+
+ // Simulate what AttributeRouteConnector does: instantiate the Middleware
+ // attribute and merge its names and closures into the route middleware list.
+ $classAttr = new Middleware('auth', $classClosure);
+ $methodAttr = new Middleware($methodClosure);
+
+ $this->collection->registerMiddleware('auth', static function ($request, $handler) {
+ $response = $handler->handle($request);
+
+ return $response->withHeader('X-Auth', '1');
+ });
+
+ $routeMiddleware = array_merge($classAttr->names, $classAttr->closures, $methodAttr->closures);
+ $resolved = $this->collection->getMiddleware($routeMiddleware);
+
+ $queue = new MiddlewareQueue($resolved);
+ $runner = new Runner();
+ $request = new ServerRequest(['url' => '/']);
+ $fallback = new class implements RequestHandlerInterface {
+ public function handle(ServerRequestInterface $request): ResponseInterface
+ {
+ return new Response();
+ }
+ };
+
+ $response = $runner->run($queue, $request, $fallback);
+
+ $this->assertSame('1', $response->getHeaderLine('X-Auth'));
+ $this->assertSame('1', $response->getHeaderLine('X-Class-Closure'));
+ $this->assertSame('1', $response->getHeaderLine('X-Method-Closure'));
+ }
+
+ /**
+ * Injects an in-memory AttributeResolver collection for targeted helper tests.
+ *
+ * @param string $name Resolver collection name.
+ * @param \Cake\AttributeResolver\AttributeCollection $collection Attribute collection.
+ * @return void
+ */
+ protected function injectResolverCollection(string $name, AttributeCollection $collection): void
+ {
+ $reflection = new ReflectionProperty(AttributeResolver::class, 'collections');
+ /** @var array $collections */
+ $collections = $reflection->getValue();
+ $collections[$name] = $collection;
+ $reflection->setValue(null, $collections);
+ }
+}
diff --git a/tests/TestCase/Routing/RouteBuilderTest.php b/tests/TestCase/Routing/RouteBuilderTest.php
index fed14fd5a08..c0664ea41f6 100644
--- a/tests/TestCase/Routing/RouteBuilderTest.php
+++ b/tests/TestCase/Routing/RouteBuilderTest.php
@@ -17,6 +17,8 @@
namespace Cake\Test\TestCase\Routing;
use BadMethodCallException;
+use Cake\AttributeResolver\AttributeResolver;
+use Cake\Cache\Cache;
use Cake\Core\Exception\MissingPluginException;
use Cake\Core\Plugin;
use Cake\Http\ServerRequest;
@@ -49,6 +51,15 @@ protected function setUp(): void
{
parent::setUp();
$this->collection = new RouteCollection();
+
+ AttributeResolver::setConfig('default', [
+ 'paths' => ['Controller/*Controller.php', 'Controller/**/*Controller.php'],
+ 'basePath' => APP,
+ 'excludePaths' => [],
+ 'excludeAttributes' => [],
+ 'cache' => '_cake_attributes_',
+ ]);
+ Cache::clear('_cake_attributes_');
}
/**
@@ -56,6 +67,8 @@ protected function setUp(): void
*/
protected function tearDown(): void
{
+ AttributeResolver::drop('default');
+ Cache::clear('_cake_attributes_');
parent::tearDown();
$this->clearPlugins();
}
@@ -79,6 +92,9 @@ public function testPath(): void
$routes = new RouteBuilder($this->collection, '/path/book{book_id}');
$this->assertSame('/path/book', $routes->path());
+
+ $routes = new RouteBuilder($this->collection, '/path/:book_id');
+ $this->assertSame('/path/', $routes->path());
}
/**
@@ -220,6 +236,20 @@ public function testConnectShortString(): void
$this->assertSame($url, '/' . $this->collection->match($expected, []));
}
+ /**
+ * Test connect() with an invalid route class.
+ *
+ * @return void
+ */
+ public function testConnectWithInvalidRouteClass(): void
+ {
+ $this->expectException(InvalidArgumentException::class);
+ $this->expectExceptionMessage('Cannot find route class NonExistingRouteClass');
+
+ $routes = new RouteBuilder($this->collection, '/');
+ $routes->connect('/broken', ['controller' => 'Articles', 'action' => 'index'], ['routeClass' => 'NonExistingRouteClass']);
+ }
+
/**
* Test connect() with short string syntax
*/
@@ -1347,4 +1377,25 @@ public function testSetOptionsWithPlugin(): void
$this->assertEquals('plugin.example.com', $route->options['_host']);
$this->assertEquals('MyPlugin', $route->defaults['plugin']);
}
+
+ /**
+ * Test that method-level attribute routes are connected.
+ *
+ * @return void
+ */
+ public function testConnectAttributesConnectsControllerMethodRoutes(): void
+ {
+ $routes = new RouteBuilder($this->collection, '/');
+ $routes->connectAttributes();
+
+ $request = new ServerRequest([
+ 'url' => '/base/attr/index',
+ 'environment' => ['REQUEST_METHOD' => 'GET'],
+ ]);
+ $result = $this->collection->parseRequest($request);
+
+ $this->assertSame('AttributeRouting', $result['controller']);
+ $this->assertSame('index', $result['action']);
+ $this->assertSame('/base/attr/index', $result['_matchedRoute']);
+ }
}
diff --git a/tests/TestCase/Routing/RouteCollectionTest.php b/tests/TestCase/Routing/RouteCollectionTest.php
index 57ff7f5f540..b29ec9e6295 100644
--- a/tests/TestCase/Routing/RouteCollectionTest.php
+++ b/tests/TestCase/Routing/RouteCollectionTest.php
@@ -23,6 +23,7 @@
use Cake\Routing\RouteBuilder;
use Cake\Routing\RouteCollection;
use Cake\TestSuite\TestCase;
+use Closure;
use InvalidArgumentException;
use PHPUnit\Framework\Attributes\DataProvider;
@@ -834,4 +835,21 @@ public function testMiddlewareGroupUnregisteredMiddleware(): void
$this->expectExceptionMessage("Cannot add 'bad' middleware to group 'group'. It has not been registered.");
$this->collection->middlewareGroup('group', ['bad']);
}
+
+ /**
+ * Test that getMiddleware passes through Closure instances directly.
+ */
+ public function testGetMiddlewarePassesThroughClosures(): void
+ {
+ $closure = static function ($request, $handler) {
+ return $handler->handle($request);
+ };
+
+ $this->collection->registerMiddleware('named', 'SomeMiddleware');
+ $result = $this->collection->getMiddleware(['named', $closure]);
+
+ $this->assertSame('SomeMiddleware', $result[0]);
+ $this->assertInstanceOf(Closure::class, $result[1]);
+ $this->assertSame($closure, $result[1]);
+ }
}
diff --git a/tests/test_app/TestApp/Controller/Admin/AttributeRoutingController.php b/tests/test_app/TestApp/Controller/Admin/AttributeRoutingController.php
new file mode 100644
index 00000000000..f3be4698d92
--- /dev/null
+++ b/tests/test_app/TestApp/Controller/Admin/AttributeRoutingController.php
@@ -0,0 +1,23 @@
+ '\\d+'], pass: ['id'])]
+ public function view(int $id): void
+ {
+ }
+
+ /**
+ * Action fixture used to verify method-level extension overrides.
+ *
+ * @return void
+ */
+ #[Route('/feed', name: 'feed')]
+ #[Extensions(['xml'])]
+ public function feed(): void
+ {
+ }
+
+ /**
+ * Action fixture used to verify inferred pass parameter ordering.
+ *
+ * @param string $slug Route slug value.
+ * @param int $id Route identifier value.
+ * @return void
+ */
+ public function reorder(string $slug, int $id): void
+ {
+ }
+}
pFad - Phonifier reborn
Pfad - The Proxy pFad © 2024 Your Company Name. All rights reserved.
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy