Skip to content

Commit 0e0e7fa

Browse files
committed
feat(app): Added controller attributes
1 parent 3473349 commit 0e0e7fa

File tree

21 files changed

+2149
-2
lines changed

21 files changed

+2149
-2
lines changed

system/CodeIgniter.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,12 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
460460

461461
$returned = $this->startController();
462462

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

467471
if (! method_exists($controller, '_remap') && ! is_callable([$controller, $this->method], false)) {
@@ -497,6 +501,11 @@ protected function handleRequest(?RouteCollectionInterface $routes, Cache $cache
497501
}
498502
}
499503

504+
// Execute controller attributes' after() methods AFTER framework filters
505+
$this->benchmark->start('route_attributes_after');
506+
$this->response = $this->router->executeAfterAttributes($this->request, $this->response);
507+
$this->benchmark->stop('route_attributes_after');
508+
500509
// Skip unnecessary processing for special Responses.
501510
if (
502511
! $this->response instanceof DownloadResponse
@@ -855,6 +864,25 @@ protected function startController()
855864
throw PageNotFoundException::forControllerNotFound($this->controller, $this->method);
856865
}
857866

867+
// Execute route attributes' before() methods
868+
// This runs after routing/validation but BEFORE expensive controller instantiation
869+
$this->benchmark->start('route_attributes_before');
870+
$attributeResponse = $this->router->executeBeforeAttributes($this->request);
871+
$this->benchmark->stop('route_attributes_before');
872+
873+
// If attribute returns a Response, short-circuit
874+
if ($attributeResponse instanceof ResponseInterface) {
875+
$this->benchmark->stop('controller_constructor');
876+
$this->benchmark->stop('controller');
877+
878+
return $attributeResponse;
879+
}
880+
881+
// If attribute returns a modified Request, use it
882+
if ($attributeResponse instanceof Request) {
883+
$this->request = $attributeResponse;
884+
}
885+
858886
return null;
859887
}
860888

system/Router/Attributes/Cache.php

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <[email protected]>
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+
if ($cached !== null) {
66+
// Validate cached data structure
67+
if (is_array($cached) && isset($cached['body'], $cached['headers'], $cached['status'])) {
68+
$response = service('response');
69+
$response->setBody($cached['body']);
70+
$response->setStatusCode($cached['status']);
71+
72+
// Mark response as served from cache to prevent re-caching
73+
$response->setHeader('X-Cached-Response', 'true');
74+
75+
// Restore headers from cached array of header name => value strings
76+
foreach ($cached['headers'] as $name => $value) {
77+
$response->setHeader($name, $value);
78+
}
79+
80+
$response->setHeader('Age', (string) (time() - ($cached['timestamp'] ?? time())));
81+
82+
return $response;
83+
}
84+
}
85+
86+
return null; // Continue to controller
87+
}
88+
89+
public function after(RequestInterface $request, ResponseInterface $response): ?ResponseInterface
90+
{
91+
// Don't re-cache if response was already served from cache
92+
if ($response->hasHeader('X-Cached-Response')) {
93+
// Remove the marker header before sending response
94+
$response->removeHeader('X-Cached-Response');
95+
96+
return null;
97+
}
98+
99+
// Only cache GET requests
100+
if ($request->getMethod() !== 'GET') {
101+
return null;
102+
}
103+
104+
$cacheKey = $this->key ?? $this->generateCacheKey($request);
105+
106+
// Convert Header objects to strings for caching
107+
$headers = [];
108+
109+
foreach ($response->headers() as $name => $header) {
110+
// Handle both single Header and array of Headers
111+
if (is_array($header)) {
112+
// Multiple headers with same name
113+
$values = [];
114+
115+
foreach ($header as $h) {
116+
$values[] = $h->getValueLine();
117+
}
118+
$headers[$name] = implode(', ', $values);
119+
} else {
120+
// Single header
121+
$headers[$name] = $header->getValueLine();
122+
}
123+
}
124+
125+
$data = [
126+
'body' => $response->getBody(),
127+
'headers' => $headers,
128+
'status' => $response->getStatusCode(),
129+
'timestamp' => time(),
130+
];
131+
132+
cache()->save($cacheKey, $data, $this->for);
133+
134+
return $response;
135+
}
136+
137+
protected function generateCacheKey(RequestInterface $request): string
138+
{
139+
return 'route_cache_' . md5(
140+
$request->getMethod() .
141+
$request->getUri()->getPath() .
142+
$request->getUri()->getQuery() .
143+
(function_exists('user_id') ? user_id() : ''),
144+
);
145+
}
146+
}
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 <[email protected]>
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 (empty($this->having)) {
62+
return [$this->by];
63+
}
64+
65+
return [$this->by => $this->having];
66+
}
67+
}

0 commit comments

Comments
 (0)