Skip to content

Commit 6824b69

Browse files
feat(app): Added controller attributes (#9745)
* feat(app): Added controller attributes * chore(app): Fix test Group use statements * build(app): Fixing style and sa issues * build(app): Fixing more analyses errors * build(app): Additional fixes * CS fixes * feat(app): Add config setting to use controller attributes or not * code fixes * Apply suggestions from code review Co-authored-by: Michal Sniatala <michal@sniatala.pl> * qa fixes * Replace md5 in cache attribute with xxh128 * cs fix --------- Co-authored-by: Michal Sniatala <michal@sniatala.pl>
1 parent 3473349 commit 6824b69

33 files changed

+2347
-9
lines changed

app/Config/Routing.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ class Routing extends BaseRouting
9696
*/
9797
public bool $autoRoute = false;
9898

99+
/**
100+
* If TRUE, the system will look for attributes on controller
101+
* class and methods that can run before and after the
102+
* controller/method.
103+
*
104+
* If FALSE, will ignore any attributes.
105+
*/
106+
public bool $useControllerAttributes = true;
107+
99108
/**
100109
* For Defined Routes.
101110
* If TRUE, will enable the use of the 'prioritize' option

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,10 +117,10 @@
117117
"phpstan:baseline": [
118118
"bash -c \"rm -rf utils/phpstan-baseline/*.neon\"",
119119
"bash -c \"touch utils/phpstan-baseline/loader.neon\"",
120-
"phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon",
120+
"phpstan analyse --ansi --generate-baseline=utils/phpstan-baseline/loader.neon --memory-limit=512M",
121121
"split-phpstan-baseline utils/phpstan-baseline/loader.neon"
122122
],
123-
"phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi",
123+
"phpstan:check": "vendor/bin/phpstan analyse --verbose --ansi --memory-limit=512M",
124124
"sa": "@analyze",
125125
"style": "@cs-fix",
126126
"test": "phpunit"

system/CodeIgniter.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
use CodeIgniter\HTTP\Method;
2626
use CodeIgniter\HTTP\RedirectResponse;
2727
use CodeIgniter\HTTP\Request;
28+
use CodeIgniter\HTTP\RequestInterface;
2829
use CodeIgniter\HTTP\ResponsableInterface;
2930
use CodeIgniter\HTTP\ResponseInterface;
3031
use CodeIgniter\HTTP\URI;
@@ -460,8 +461,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
460461

461462
$returned = $this->startController();
462463

464+
// If startController returned a Response (from an attribute or Closure), use it
465+
if ($returned instanceof ResponseInterface) {
466+
$this->gatherOutput($cacheConfig, $returned);
467+
}
463468
// Closure controller has run in startController().
464-
if (! is_callable($this->controller)) {
469+
elseif (! is_callable($this->controller)) {
465470
$controller = $this->createController();
466471

467472
if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
@@ -497,6 +502,13 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
497502
}
498503
}
499504

505+
// Execute controller attributes' after() methods AFTER framework filters
506+
if ((config('Routing')->useControllerAttributes ?? true) === true) {
507+
$this->benchmark->start('route_attributes_after');
508+
$this->response = $this->router->executeAfterAttributes($this->request, $this->response);
509+
$this->benchmark->stop('route_attributes_after');
510+
}
511+
500512
// Skip unnecessary processing for special Responses.
501513
if (
502514
! $this->response instanceof DownloadResponse
@@ -855,6 +867,27 @@ protected function startController()
855867
throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
856868
}
857869

870+
// Execute route attributes' before() methods
871+
// This runs after routing/validation but BEFORE expensive controller instantiation
872+
if ((config('Routing')->useControllerAttributes ?? true) === true) {
873+
$this->benchmark->start('route_attributes_before');
874+
$attributeResponse = $this->router->executeBeforeAttributes($this->request);
875+
$this->benchmark->stop('route_attributes_before');
876+
877+
// If attribute returns a Response, short-circuit
878+
if ($attributeResponse instanceof ResponseInterface) {
879+
$this->benchmark->stop('controller_constructor');
880+
$this->benchmark->stop('controller');
881+
882+
return $attributeResponse;
883+
}
884+
885+
// If attribute returns a modified Request, use it
886+
if ($attributeResponse instanceof RequestInterface) {
887+
$this->request = $attributeResponse;
888+
}
889+
}
890+
858891
return null;
859892
}
860893

system/Config/Routing.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ class Routing extends BaseConfig
9696
*/
9797
public bool $autoRoute = false;
9898

99+
/**
100+
* If TRUE, the system will look for attributes on controller
101+
* class and methods that can run before and after the
102+
* controller/method.
103+
*
104+
* If FALSE, will ignore any attributes.
105+
*/
106+
public bool $useControllerAttributes = true;
107+
99108
/**
100109
* For Defined Routes.
101110
* If TRUE, will enable the use of the 'prioritize' option

system/Router/Attributes/Cache.php

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Router\Attributes;
15+
16+
use Attribute;
17+
use CodeIgniter\HTTP\RequestInterface;
18+
use CodeIgniter\HTTP\ResponseInterface;
19+
20+
/**
21+
* Cache Attribute
22+
*
23+
* Caches the response of a controller method at the server level for a specified duration.
24+
* This is server-side caching to avoid expensive operations, not browser-level caching.
25+
*
26+
* Usage:
27+
* ```php
28+
* #[Cache(for: 3600)] // Cache for 1 hour
29+
* #[Cache(for: 300, key: 'custom_key')] // Cache with custom key
30+
* ```
31+
*
32+
* Limitations:
33+
* - Only caches GET requests; POST, PUT, DELETE, and other methods are ignored
34+
* - Streaming responses or file downloads may not cache properly
35+
* - Cache key includes HTTP method, path, query string, and possibly user_id(), but not request headers
36+
* - Does not automatically invalidate related cache entries
37+
* - Cookies set in the response are cached and reused for all subsequent requests
38+
* - Large responses may impact cache storage performance
39+
* - Browser Cache-Control headers do not affect server-side caching behavior
40+
*
41+
* Security Considerations:
42+
* - Ensure cache backend is properly secured and not accessible publicly
43+
* - Be aware that authorization checks happen before cache lookup
44+
*/
45+
#[Attribute(Attribute::TARGET_METHOD)]
46+
class Cache implements RouteAttributeInterface
47+
{
48+
public function __construct(
49+
public int $for = 3600,
50+
public ?string $key = null,
51+
) {
52+
}
53+
54+
public function before(RequestInterface $request): RequestInterface|ResponseInterface|null
55+
{
56+
// Only cache GET requests
57+
if ($request->getMethod() !== 'GET') {
58+
return null;
59+
}
60+
61+
// Check cache before controller execution
62+
$cacheKey = $this->key ?? $this->generateCacheKey($request);
63+
64+
$cached = cache($cacheKey);
65+
// Validate cached data structure
66+
if ($cached !== null && (is_array($cached) && isset($cached['body'], $cached['headers'], $cached['status']))) {
67+
$response = service('response');
68+
$response->setBody($cached['body']);
69+
$response->setStatusCode($cached['status']);
70+
// Mark response as served from cache to prevent re-caching
71+
$response->setHeader('X-Cached-Response', 'true');
72+
73+
// Restore headers from cached array of header name => value strings
74+
foreach ($cached['headers'] as $name => $value) {
75+
$response->setHeader($name, $value);
76+
}
77+
$response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time())));
78+
79+
return $response;
80+
}
81+
82+
return null; // Continue to controller
83+
}
84+
85+
public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface
86+
{
87+
// Don't re-cache if response was already served from cache
88+
if ($response->hasHeader('X-Cached-Response')) {
89+
// Remove the marker header before sending response
90+
$response->removeHeader('X-Cached-Response');
91+
92+
return null;
93+
}
94+
95+
// Only cache GET requests
96+
if ($request->getMethod() !== 'GET') {
97+
return null;
98+
}
99+
100+
$cacheKey = $this->key ?? $this->generateCacheKey($request);
101+
102+
// Convert Header objects to strings for caching
103+
$headers = [];
104+
105+
foreach ($response->headers() as $name => $header) {
106+
// Handle both single Header and array of Headers
107+
if (is_array($header)) {
108+
// Multiple headers with same name
109+
$values = [];
110+
111+
foreach ($header as $h) {
112+
$values[] = $h->getValueLine();
113+
}
114+
$headers[$name] = implode(', ', $values);
115+
} else {
116+
// Single header
117+
$headers[$name] = $header->getValueLine();
118+
}
119+
}
120+
121+
$data = [
122+
'body' => $response->getBody(),
123+
'headers' => $headers,
124+
'status' => $response->getStatusCode(),
125+
'timestamp' => time(),
126+
];
127+
128+
cache()->save($cacheKey, $data, $this->for);
129+
130+
return $response;
131+
}
132+
133+
protected function generateCacheKey(RequestInterface $request): string
134+
{
135+
return 'route_cache_' . hash(
136+
'xxh128',
137+
$request->getMethod() .
138+
$request->getUri()->getPath() .
139+
$request->getUri()->getQuery() .
140+
(function_exists('user_id') ? user_id() : ''),
141+
);
142+
}
143+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Router\Attributes;
15+
16+
use Attribute;
17+
use CodeIgniter\HTTP\RequestInterface;
18+
use CodeIgniter\HTTP\ResponseInterface;
19+
20+
/**
21+
* Filter Attribute
22+
*
23+
* Applies CodeIgniter filters to controller classes or methods. Filters can perform
24+
* operations before or after controller execution, such as authentication, CSRF protection,
25+
* rate limiting, or request/response manipulation.
26+
*
27+
* Limitations:
28+
* - Filter must be registered in Config\Filters.php or won't be found
29+
* - Does not validate filter existence at attribute definition time
30+
* - Cannot conditionally apply filters based on runtime conditions
31+
* - Class-level filters cannot be overridden or disabled for specific methods
32+
*
33+
* Security Considerations:
34+
* - Filters run in the order specified; authentication should typically come first
35+
* - Don't rely solely on filters for critical security; validate in controllers too
36+
* - Ensure sensitive filters are registered as globals if they should apply site-wide
37+
*/
38+
#[Attribute(Attribute::TARGET_CLASS | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
39+
class Filter implements RouteAttributeInterface
40+
{
41+
public function __construct(
42+
public string $by,
43+
public array $having = [],
44+
) {
45+
}
46+
47+
public function before(RequestInterface $request): RequestInterface|ResponseInterface|null
48+
{
49+
// Filters are handled by the filter system via getFilters()
50+
// No processing needed here
51+
return null;
52+
}
53+
54+
public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface
55+
{
56+
return null;
57+
}
58+
59+
public function getFilters(): array
60+
{
61+
if ($this->having === []) {
62+
return [$this->by];
63+
}
64+
65+
return [$this->by => $this->having];
66+
}
67+
}

0 commit comments

Comments
 (0)