From d187bd46f294d540205f6abe10ba7636901f1958 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 26 Nov 2024 14:09:16 -0600 Subject: [PATCH 01/19] feat(etag): add ETag middleware and customizable ETag generation - Introduced IfNoneMatch middleware to handle ETag-based caching: - Supports safe HTTP methods (GET, HEAD). - Returns "304 Not Modified" for matching ETags. - Handles multiple ETags and restores original request methods. - Added EtagManager for flexible ETag generation: - Default ETag generation uses MD5 hash. - Supports custom hash algorithms like SHA-256 via callbacks. - Included test cases: - Validate default ETag generation. - Test custom callback logic for ETag creation. This commit adds foundational support for ETag-based caching in the application. --- ProcessMaker/Http/Kernel.php | 4 +- .../Http/Middleware/Etag/IfNoneMatch.php | 48 +++++++++++++++++ ProcessMaker/Http/Middleware/Etag/SetEtag.php | 39 ++++++++++++++ .../Http/Resources/Caching/EtagManager.php | 44 ++++++++++++++++ tests/Feature/Etag/EtagManagerTest.php | 51 +++++++++++++++++++ tests/Feature/Etag/IfNoneMatchTest.php | 48 +++++++++++++++++ tests/Feature/Etag/SetEtagTest.php | 36 +++++++++++++ 7 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php create mode 100644 ProcessMaker/Http/Middleware/Etag/SetEtag.php create mode 100644 ProcessMaker/Http/Resources/Caching/EtagManager.php create mode 100644 tests/Feature/Etag/EtagManagerTest.php create mode 100644 tests/Feature/Etag/IfNoneMatchTest.php create mode 100644 tests/Feature/Etag/SetEtagTest.php diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index 5fb8432f48..eb15cdd029 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -41,7 +41,7 @@ class Kernel extends HttpKernel \Illuminate\Routing\Middleware\SubstituteBindings::class, Middleware\GenerateMenus::class, \Laravel\Passport\Http\Middleware\CreateFreshApiToken::class, - \ProcessMaker\Http\Middleware\IgnoreMapFiles::class, + Middleware\IgnoreMapFiles::class, ], 'api' => [ // API Middleware is defined with routeMiddleware below. @@ -83,6 +83,8 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, + 'etag.set' => Middleware\Etag\SetEtag::class, + 'etag.if-none-match' => Middleware\Etag\IfNoneMatch::class, ]; /** diff --git a/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php b/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php new file mode 100644 index 0000000000..a6569996b3 --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php @@ -0,0 +1,48 @@ +getMethod(); + + // Support using HEAD method for checking If-None-Match. + if ($request->isMethod('HEAD')) { + $request->setMethod('GET'); + } + + //Handle response. + $response = $next($request); + + // If the response is not modified, return it. + if ($response->isNotModified($request)) { + return $response; + } + + // Get the ETag value. + $etag = EtagManager::getEtag($request, $response); + + // Check if the ETag matches the If-None-Match header. + $noneMatch = array_map('trim', $request->getETags()); + if (in_array($etag, $noneMatch)) { + $response->setNotModified(); + } + + $request->setMethod($method); + + return $response; + } +} diff --git a/ProcessMaker/Http/Middleware/Etag/SetEtag.php b/ProcessMaker/Http/Middleware/Etag/SetEtag.php new file mode 100644 index 0000000000..169aee3e27 --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/SetEtag.php @@ -0,0 +1,39 @@ +getMethod(); + + // Support using HEAD method for checking If-None-Match. + if ($request->isMethod('HEAD')) { + $request->setMethod('GET'); + } + + // Handle response. + $response = $next($request); + + // Setting ETag. + $etag = EtagManager::getEtag($request, $response); + $response->setEtag($etag); + $request->setMethod($method); + + return $response; + } +} diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php new file mode 100644 index 0000000000..80a9cd4f6b --- /dev/null +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -0,0 +1,44 @@ +start('"')->finish('"'); + } + + /** + * Get default ETag value. + */ + private static function defaultGetEtag(Response $response): string + { + return md5($response->getContent()); + } +} diff --git a/tests/Feature/Etag/EtagManagerTest.php b/tests/Feature/Etag/EtagManagerTest.php new file mode 100644 index 0000000000..010c28ebbb --- /dev/null +++ b/tests/Feature/Etag/EtagManagerTest.php @@ -0,0 +1,51 @@ +response, 200); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackMd5() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return md5($response->getContent()); + }); + + $this->assertEquals('"e0aa021e21dddbd6d8cecec71e9cf564"', EtagManager::getEtag($request, $response)); + } + + public function testGetEtagWithCallbackSha256() + { + $request = Request::create('/', 'GET'); + $response = response($this->response, 200); + + EtagManager::etagGenerateUsing(function (Request $request, Response $response) { + return hash('sha256', $response->getContent()); + }); + + $expectedHash = hash('sha256', $this->response); + $this->assertEquals("\"$expectedHash\"", EtagManager::getEtag($request, $response)); + } +} diff --git a/tests/Feature/Etag/IfNoneMatchTest.php b/tests/Feature/Etag/IfNoneMatchTest.php new file mode 100644 index 0000000000..15455aa2b3 --- /dev/null +++ b/tests/Feature/Etag/IfNoneMatchTest.php @@ -0,0 +1,48 @@ +any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + } + + public function testGetRequestStatus200WithNoneMatchingIfNoneMatch() + { + $noneMatch = '"' . md5($this->response . 'NoneMatch') . '"'; + $response = $this + ->withHeaders([ + 'If-None-Match' => $noneMatch, + ]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(200); + } + + public function testGetRequestStatus304WithMatchingIfNoneMatch() + { + $noneMatch = '"' . md5($this->response) . '"'; + $response = $this + ->withHeaders([ + 'If-None-Match' => $noneMatch, + ]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + } +} diff --git a/tests/Feature/Etag/SetEtagTest.php b/tests/Feature/Etag/SetEtagTest.php new file mode 100644 index 0000000000..754bdd5597 --- /dev/null +++ b/tests/Feature/Etag/SetEtagTest.php @@ -0,0 +1,36 @@ +any(self::TEST_ROUTE, function () { + return $this->response; + }); + } + + public function testMiddlewareSetsEtagHeader() + { + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag', null); + } + + public function testEtagHeaderHasCorrectValue() + { + $value = '"' . md5($this->response) . '"'; + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag', $value); + } +} From d3e4d2e1a028a49f2e95dfa2e8684a4e72f23dfe Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 28 Nov 2024 10:34:24 -0600 Subject: [PATCH 02/19] refactor: combine ETag generation and validation into a single middleware - Merged `etag.set` and `etag.if-none-match` middlewares into a single `etag.handle` middleware. - Simplified logic to reduce redundancy and improve maintainability. - Ensured ETag validation (`If-None-Match`) and generation are handled in the same flow. - Preserved compatibility with HEAD requests for consistency. This refactor improves clarity, reduces potential misconfigurations, and keeps the ETag logic centralized. --- ProcessMaker/Http/Kernel.php | 3 +- .../Http/Middleware/Etag/HandleEtag.php | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 ProcessMaker/Http/Middleware/Etag/HandleEtag.php diff --git a/ProcessMaker/Http/Kernel.php b/ProcessMaker/Http/Kernel.php index eb15cdd029..522bfba009 100644 --- a/ProcessMaker/Http/Kernel.php +++ b/ProcessMaker/Http/Kernel.php @@ -83,8 +83,7 @@ class Kernel extends HttpKernel 'session_kill' => Middleware\SessionControlKill::class, 'no-cache' => Middleware\NoCache::class, 'admin' => Middleware\IsAdmin::class, - 'etag.set' => Middleware\Etag\SetEtag::class, - 'etag.if-none-match' => Middleware\Etag\IfNoneMatch::class, + 'etag' => Middleware\Etag\HandleEtag::class, ]; /** diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php new file mode 100644 index 0000000000..122e70c45c --- /dev/null +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -0,0 +1,56 @@ +getMethod(); + if ($request->isMethod('HEAD')) { + $request->setMethod('GET'); + } + + // Handle response. + $response = $next($request); + + // Generate ETag for the response. + $etag = EtagManager::getEtag($request, $response); + if ($etag) { + // Set the ETag header. + $response->setEtag($etag); + + // Get and strip weak ETags from request headers. + $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + + // Compare ETags and set response as not modified if applicable. + if (in_array($etag, $noneMatch)) { + $response->setNotModified(); + } + } + + // Restore original method and return the response. + $request->setMethod($originalMethod); + + return $response; + } + + /** + * Remove the weak indicator (W/) from an ETag. + */ + private function stripWeakTags(string $etag): string + { + return str_replace('W/', '', $etag); + } +} From d5dd9a8bf6c04b412e06c686bacdb708cab6614e Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 28 Nov 2024 10:39:09 -0600 Subject: [PATCH 03/19] refactor: add tests for combined ETag middleware and remove deprecated tests - Created tests for the new `HandleEtag` middleware: - Validates ETag generation and correctness. - Tests responses for both matching and non-matching `If-None-Match` headers. - Ensures proper handling of weak ETags (`W/`). - Removed old tests for `SetEtag` and `IfNoneMatch` middlewares as they are no longer needed. This commit improves test clarity and ensures the new ETag middleware behaves as expected. --- .../Http/Middleware/Etag/IfNoneMatch.php | 48 ------------- ProcessMaker/Http/Middleware/Etag/SetEtag.php | 39 ----------- tests/Feature/Etag/HandleEtagTest.php | 70 +++++++++++++++++++ tests/Feature/Etag/IfNoneMatchTest.php | 48 ------------- tests/Feature/Etag/SetEtagTest.php | 36 ---------- 5 files changed, 70 insertions(+), 171 deletions(-) delete mode 100644 ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php delete mode 100644 ProcessMaker/Http/Middleware/Etag/SetEtag.php create mode 100644 tests/Feature/Etag/HandleEtagTest.php delete mode 100644 tests/Feature/Etag/IfNoneMatchTest.php delete mode 100644 tests/Feature/Etag/SetEtagTest.php diff --git a/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php b/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php deleted file mode 100644 index a6569996b3..0000000000 --- a/ProcessMaker/Http/Middleware/Etag/IfNoneMatch.php +++ /dev/null @@ -1,48 +0,0 @@ -getMethod(); - - // Support using HEAD method for checking If-None-Match. - if ($request->isMethod('HEAD')) { - $request->setMethod('GET'); - } - - //Handle response. - $response = $next($request); - - // If the response is not modified, return it. - if ($response->isNotModified($request)) { - return $response; - } - - // Get the ETag value. - $etag = EtagManager::getEtag($request, $response); - - // Check if the ETag matches the If-None-Match header. - $noneMatch = array_map('trim', $request->getETags()); - if (in_array($etag, $noneMatch)) { - $response->setNotModified(); - } - - $request->setMethod($method); - - return $response; - } -} diff --git a/ProcessMaker/Http/Middleware/Etag/SetEtag.php b/ProcessMaker/Http/Middleware/Etag/SetEtag.php deleted file mode 100644 index 169aee3e27..0000000000 --- a/ProcessMaker/Http/Middleware/Etag/SetEtag.php +++ /dev/null @@ -1,39 +0,0 @@ -getMethod(); - - // Support using HEAD method for checking If-None-Match. - if ($request->isMethod('HEAD')) { - $request->setMethod('GET'); - } - - // Handle response. - $response = $next($request); - - // Setting ETag. - $etag = EtagManager::getEtag($request, $response); - $response->setEtag($etag); - $request->setMethod($method); - - return $response; - } -} diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php new file mode 100644 index 0000000000..43b702bd6f --- /dev/null +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -0,0 +1,70 @@ +any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + } + + public function testMiddlewareSetsEtagHeader() + { + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag'); + } + + public function testEtagHeaderHasCorrectValue() + { + $expectedEtag = '"' . md5($this->response) . '"'; + $response = $this->get(self::TEST_ROUTE); + $response->assertHeader('ETag', $expectedEtag); + } + + public function testRequestReturns200WhenIfNoneMatchDoesNotMatch() + { + $noneMatch = '"' . md5($this->response . 'NoneMatch') . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $noneMatch]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(200); + $response->assertHeader('ETag'); + } + + public function testRequestReturns304WhenIfNoneMatchMatches() + { + $matchingEtag = '"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $matchingEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', $matchingEtag); + } + + public function testRequestIgnoresWeakEtagsInIfNoneMatch() + { + $weakEtag = 'W/"' . md5($this->response) . '"'; + $response = $this + ->withHeaders(['If-None-Match' => $weakEtag]) + ->get(self::TEST_ROUTE); + + $response->assertStatus(304); + $response->assertHeader('ETag', '"' . md5($this->response) . '"'); + } +} diff --git a/tests/Feature/Etag/IfNoneMatchTest.php b/tests/Feature/Etag/IfNoneMatchTest.php deleted file mode 100644 index 15455aa2b3..0000000000 --- a/tests/Feature/Etag/IfNoneMatchTest.php +++ /dev/null @@ -1,48 +0,0 @@ -any(self::TEST_ROUTE, function () { - return response($this->response, 200); - }); - } - - public function testGetRequestStatus200WithNoneMatchingIfNoneMatch() - { - $noneMatch = '"' . md5($this->response . 'NoneMatch') . '"'; - $response = $this - ->withHeaders([ - 'If-None-Match' => $noneMatch, - ]) - ->get(self::TEST_ROUTE); - - $response->assertStatus(200); - } - - public function testGetRequestStatus304WithMatchingIfNoneMatch() - { - $noneMatch = '"' . md5($this->response) . '"'; - $response = $this - ->withHeaders([ - 'If-None-Match' => $noneMatch, - ]) - ->get(self::TEST_ROUTE); - - $response->assertStatus(304); - } -} diff --git a/tests/Feature/Etag/SetEtagTest.php b/tests/Feature/Etag/SetEtagTest.php deleted file mode 100644 index 754bdd5597..0000000000 --- a/tests/Feature/Etag/SetEtagTest.php +++ /dev/null @@ -1,36 +0,0 @@ -any(self::TEST_ROUTE, function () { - return $this->response; - }); - } - - public function testMiddlewareSetsEtagHeader() - { - $response = $this->get(self::TEST_ROUTE); - $response->assertHeader('ETag', null); - } - - public function testEtagHeaderHasCorrectValue() - { - $value = '"' . md5($this->response) . '"'; - $response = $this->get(self::TEST_ROUTE); - $response->assertHeader('ETag', $value); - } -} From bc39c242f9e4d78a7ce535ce1c558338112d9106 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 28 Nov 2024 11:48:09 -0600 Subject: [PATCH 04/19] test: add middleware tests for user-specific ETag generation - Added a test to validate ETag generation for user-specific routes using `etag:user`. - Simulates an authenticated user and verifies the ETag includes the user ID. These tests ensure the ETag middleware behaves correctly for both user-specific and common routes. --- .../Http/Middleware/Etag/HandleEtag.php | 7 +++++-- .../Http/Resources/Caching/EtagManager.php | 14 +++++++++----- tests/Feature/Etag/HandleEtagTest.php | 17 +++++++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index 122e70c45c..7238c1cb81 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -14,7 +14,7 @@ class HandleEtag /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next): Response + public function handle(Request $request, Closure $next, ?string $scope = null): Response { // Save original method and support HEAD requests. $originalMethod = $request->getMethod(); @@ -25,8 +25,11 @@ public function handle(Request $request, Closure $next): Response // Handle response. $response = $next($request); + // Determine if the ETag should include user-specific data. + $includeUser = $scope === 'user'; + // Generate ETag for the response. - $etag = EtagManager::getEtag($request, $response); + $etag = EtagManager::getEtag($request, $response, $includeUser); if ($etag) { // Set the ETag header. $response->setEtag($etag); diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php index 80a9cd4f6b..e1f335e1e8 100644 --- a/ProcessMaker/Http/Resources/Caching/EtagManager.php +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -25,20 +25,24 @@ public static function etagGenerateUsing(?Closure $callback): void /** * Get ETag value for this request and response. */ - public static function getEtag(Request $request, Response $response): string + public static function getEtag(Request $request, Response $response, bool $includeUser = false): string { $etag = static::$etagGenerateCallback - ? call_user_func(static::$etagGenerateCallback, $request, $response) - : static::defaultGetEtag($response); + ? call_user_func(static::$etagGenerateCallback, $request, $response, $includeUser) + : static::defaultGetEtag($response, $includeUser); return (string) Str::of($etag)->start('"')->finish('"'); } /** - * Get default ETag value. + * Generate an ETag, optionally including user-specific data. */ - private static function defaultGetEtag(Response $response): string + private static function defaultGetEtag(Response $response, bool $includeUser = false): string { + if ($includeUser) { + return md5(auth()->id() . $response->getContent()); + } + return md5($response->getContent()); } } diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php index 43b702bd6f..08ee467e9b 100644 --- a/tests/Feature/Etag/HandleEtagTest.php +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -4,6 +4,8 @@ use Illuminate\Support\Facades\Route; use ProcessMaker\Http\Middleware\Etag\HandleEtag; +use ProcessMaker\Http\Resources\Caching\EtagManager; +use ProcessMaker\Models\User; use Tests\TestCase; class HandleEtagTest extends TestCase @@ -67,4 +69,19 @@ public function testRequestIgnoresWeakEtagsInIfNoneMatch() $response->assertStatus(304); $response->assertHeader('ETag', '"' . md5($this->response) . '"'); } + + public function testDefaultGetEtagGeneratesCorrectEtagWithUser() + { + $user = User::factory()->create(); + $this->actingAs($user); + + Route::middleware('etag:user')->any(self::TEST_ROUTE, function () { + return response($this->response, 200); + }); + + $response = $this->get(self::TEST_ROUTE); + + $expectedEtag = '"' . md5($user->id . $this->response) . '"'; + $response->assertHeader('ETag', $expectedEtag); + } } From ea9795cfd55d1e1b53abf00b26c2eda8e493d6ab Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 28 Nov 2024 13:04:48 -0600 Subject: [PATCH 05/19] Add ETag to Screen Responses --- routes/v1_1/api.php | 1 + 1 file changed, 1 insertion(+) diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index a2b2328dc9..ead0037612 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -22,6 +22,7 @@ // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) + ->middleware('etag') ->name('show.screen'); // Route to show the interstitial screen of a task From f058177cc7f42a35d9c6b5cd82ff661ddba60e2d Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 2 Dec 2024 11:05:34 -0600 Subject: [PATCH 06/19] Exclude 'active_at' field from TaskResource and requestor serialization to prevent ETag inconsistencies --- ProcessMaker/Http/Resources/V1_1/TaskResource.php | 1 - ProcessMaker/Traits/TaskResourceIncludes.php | 7 ++++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/ProcessMaker/Http/Resources/V1_1/TaskResource.php b/ProcessMaker/Http/Resources/V1_1/TaskResource.php index c577942c8f..cec32ced7e 100644 --- a/ProcessMaker/Http/Resources/V1_1/TaskResource.php +++ b/ProcessMaker/Http/Resources/V1_1/TaskResource.php @@ -82,7 +82,6 @@ class TaskResource extends ApiResource 'is_administrator', 'expires_at', 'loggedin_at', - 'active_at', 'created_at', 'updated_at', 'delegation_user_id', diff --git a/ProcessMaker/Traits/TaskResourceIncludes.php b/ProcessMaker/Traits/TaskResourceIncludes.php index ec12530a4c..f2597ae68c 100644 --- a/ProcessMaker/Traits/TaskResourceIncludes.php +++ b/ProcessMaker/Traits/TaskResourceIncludes.php @@ -41,7 +41,12 @@ private function includeUser() private function includeRequestor() { - return ['requestor' => new Users($this->processRequest->user)]; + $user = $this->processRequest->user; + + // Exclude 'active_at' to prevent ETag inconsistencies. + $user->makeHidden(['active_at']); + + return ['requestor' => new Users($user)]; } private function includeProcessRequest() From 48cd7d672ba91c3e18ee8a7122bc5371236542f4 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 2 Dec 2024 11:06:30 -0600 Subject: [PATCH 07/19] Implement ETag Caching for Task Responses --- routes/api.php | 4 +++- routes/v1_1/api.php | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/routes/api.php b/routes/api.php index 947297d3de..8966fc0fc7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -208,7 +208,9 @@ Route::post('tasks/{task}/setViewed', [TaskController::class, 'setViewed'])->name('tasks.set_viewed')->middleware('can:viewScreen,task,screen'); Route::put('tasks/{task}/setPriority', [TaskController::class, 'setPriority'])->name('tasks.priority'); Route::put('tasks/updateReassign', [TaskController::class, 'updateReassign'])->name('tasks.updateReassign'); - Route::get('tasks/user-can-reassign', [TaskController::class, 'userCanReassign'])->name('tasks.user_can_reassign'); + Route::get('tasks/user-can-reassign', [TaskController::class, 'userCanReassign']) + ->middleware('etag:user') + ->name('tasks.user_can_reassign'); // TaskDrafts Route::put('drafts/{task}', [TaskDraftController::class, 'update'])->name('taskdraft.update'); diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index ead0037612..57ecc2a694 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -18,7 +18,7 @@ // Route to show a task Route::get('/{task}', [TaskController::class, 'show']) ->name('show') - ->middleware(['bindings', 'can:view,task']); + ->middleware(['bindings', 'can:view,task', 'etag:user']); // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) From 8b327c8a016ed5674acda8cb68511a0679e9bd0e Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 3 Dec 2024 15:13:16 -0600 Subject: [PATCH 08/19] restrict ETag handling to GET/HEAD and enforce user-specific caching - Process only GET and HEAD methods to ensure middleware relevance and avoid unnecessary processing for non-cacheable HTTP methods. - Add a check to determine if the response is cacheable, filtering out non-cacheable responses (e.g., those with 'no-store' directive or non-cacheable status codes). - Default ETag generation now includes user-specific data (auth()->id()) to enforce personalized caching by default. - Removed 'scope' and 'includeUser' logic for simplified and consistent caching behavior. --- .../Http/Middleware/Etag/HandleEtag.php | 33 ++++++++++++------- .../Http/Resources/Caching/EtagManager.php | 18 ++++------ 2 files changed, 29 insertions(+), 22 deletions(-) diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index 7238c1cb81..3dca9e62f2 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -14,22 +14,23 @@ class HandleEtag /** * Handle an incoming request. */ - public function handle(Request $request, Closure $next, ?string $scope = null): Response + public function handle(Request $request, Closure $next): Response { - // Save original method and support HEAD requests. - $originalMethod = $request->getMethod(); - if ($request->isMethod('HEAD')) { - $request->setMethod('GET'); + // Process only GET and HEAD methods. + if (!$request->isMethod('GET') && !$request->isMethod('HEAD')) { + return $next($request); } // Handle response. $response = $next($request); - // Determine if the ETag should include user-specific data. - $includeUser = $scope === 'user'; + // Check if the response is cacheable. + if (!$this->isCacheableResponse($response)) { + return $response; // Skip ETag for non-cacheable responses. + } // Generate ETag for the response. - $etag = EtagManager::getEtag($request, $response, $includeUser); + $etag = EtagManager::getEtag($request, $response); if ($etag) { // Set the ETag header. $response->setEtag($etag); @@ -43,9 +44,6 @@ public function handle(Request $request, Closure $next, ?string $scope = null): } } - // Restore original method and return the response. - $request->setMethod($originalMethod); - return $response; } @@ -56,4 +54,17 @@ private function stripWeakTags(string $etag): string { return str_replace('W/', '', $etag); } + + /** + * Determine if a response is cacheable. + */ + private function isCacheableResponse(Response $response): bool + { + $cacheableStatusCodes = [200, 203, 204, 206, 304]; + $cacheControl = $response->headers->get('Cache-Control', ''); + + // Verify if the status code is cacheable and does not contain "no-store". + return in_array($response->getStatusCode(), $cacheableStatusCodes) + && !str_contains($cacheControl, 'no-store'); + } } diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php index e1f335e1e8..5344df06a0 100644 --- a/ProcessMaker/Http/Resources/Caching/EtagManager.php +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -23,26 +23,22 @@ public static function etagGenerateUsing(?Closure $callback): void } /** - * Get ETag value for this request and response. + * Get the ETag value for this request and response. */ - public static function getEtag(Request $request, Response $response, bool $includeUser = false): string + public static function getEtag(Request $request, Response $response): string { $etag = static::$etagGenerateCallback - ? call_user_func(static::$etagGenerateCallback, $request, $response, $includeUser) - : static::defaultGetEtag($response, $includeUser); + ? call_user_func(static::$etagGenerateCallback, $request, $response) + : static::defaultGetEtag($response); return (string) Str::of($etag)->start('"')->finish('"'); } /** - * Generate an ETag, optionally including user-specific data. + * Generate a default ETag, including user-specific data by default. */ - private static function defaultGetEtag(Response $response, bool $includeUser = false): string + private static function defaultGetEtag(Response $response): string { - if ($includeUser) { - return md5(auth()->id() . $response->getContent()); - } - - return md5($response->getContent()); + return md5(auth()->id() . $response->getContent()); } } From dae24684fea905125f148b8db8e5318aeef6f4d4 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 5 Dec 2024 12:02:15 -0600 Subject: [PATCH 09/19] Add ETag middleware to API group to support package routes --- ProcessMaker/Providers/ProcessMakerServiceProvider.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ProcessMaker/Providers/ProcessMakerServiceProvider.php b/ProcessMaker/Providers/ProcessMakerServiceProvider.php index ce11b8e437..f8994be5a4 100644 --- a/ProcessMaker/Providers/ProcessMakerServiceProvider.php +++ b/ProcessMaker/Providers/ProcessMakerServiceProvider.php @@ -10,6 +10,7 @@ use Illuminate\Notifications\Events\BroadcastNotificationCreated; use Illuminate\Notifications\Events\NotificationSent; use Illuminate\Support\Facades; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\URL; use Laravel\Dusk\DuskServiceProvider; use Laravel\Horizon\Horizon; @@ -19,6 +20,7 @@ use ProcessMaker\Events\ActivityAssigned; use ProcessMaker\Events\ScreenBuilderStarting; use ProcessMaker\Helpers\PmHash; +use ProcessMaker\Http\Middleware\Etag\HandleEtag; use ProcessMaker\ImportExport\Extension; use ProcessMaker\ImportExport\SignalHelper; use ProcessMaker\Jobs\SmartInbox; @@ -52,6 +54,8 @@ public function boot(): void $this->setupFactories(); parent::boot(); + + Route::pushMiddlewareToGroup('api', HandleEtag::class); } public function register(): void From 8a94369fd213988a5bae094667eccf5ca5143074 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 5 Dec 2024 12:03:22 -0600 Subject: [PATCH 10/19] Apply ETag middleware to v1.1 routes for consistent caching --- ProcessMaker/Providers/RouteServiceProvider.php | 2 +- routes/api.php | 4 +--- routes/v1_1/api.php | 3 +-- tests/Feature/Etag/HandleEtagTest.php | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) diff --git a/ProcessMaker/Providers/RouteServiceProvider.php b/ProcessMaker/Providers/RouteServiceProvider.php index 1315214a1d..e12823a721 100644 --- a/ProcessMaker/Providers/RouteServiceProvider.php +++ b/ProcessMaker/Providers/RouteServiceProvider.php @@ -89,7 +89,7 @@ protected function mapApiRoutes() { Route::middleware('api') ->group(base_path('routes/api.php')); - Route::middleware('auth:api') + Route::middleware(['auth:api', 'etag']) ->group(base_path('routes/v1_1/api.php')); } diff --git a/routes/api.php b/routes/api.php index 8966fc0fc7..947297d3de 100644 --- a/routes/api.php +++ b/routes/api.php @@ -208,9 +208,7 @@ Route::post('tasks/{task}/setViewed', [TaskController::class, 'setViewed'])->name('tasks.set_viewed')->middleware('can:viewScreen,task,screen'); Route::put('tasks/{task}/setPriority', [TaskController::class, 'setPriority'])->name('tasks.priority'); Route::put('tasks/updateReassign', [TaskController::class, 'updateReassign'])->name('tasks.updateReassign'); - Route::get('tasks/user-can-reassign', [TaskController::class, 'userCanReassign']) - ->middleware('etag:user') - ->name('tasks.user_can_reassign'); + Route::get('tasks/user-can-reassign', [TaskController::class, 'userCanReassign'])->name('tasks.user_can_reassign'); // TaskDrafts Route::put('drafts/{task}', [TaskDraftController::class, 'update'])->name('taskdraft.update'); diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index 57ecc2a694..a2b2328dc9 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -18,11 +18,10 @@ // Route to show a task Route::get('/{task}', [TaskController::class, 'show']) ->name('show') - ->middleware(['bindings', 'can:view,task', 'etag:user']); + ->middleware(['bindings', 'can:view,task']); // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) - ->middleware('etag') ->name('show.screen'); // Route to show the interstitial screen of a task diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php index 08ee467e9b..e933260f04 100644 --- a/tests/Feature/Etag/HandleEtagTest.php +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -75,7 +75,7 @@ public function testDefaultGetEtagGeneratesCorrectEtagWithUser() $user = User::factory()->create(); $this->actingAs($user); - Route::middleware('etag:user')->any(self::TEST_ROUTE, function () { + Route::middleware('etag')->any(self::TEST_ROUTE, function () { return response($this->response, 200); }); From d748403ba9da105358ab0b575e1abaf89ad1e324 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 5 Dec 2024 15:53:28 -0600 Subject: [PATCH 11/19] feat: add flexibility to ETag generation with sources - Refactored `EtagManager` to support dynamic ETag generation based on configurable sources (`updated_at`). - Introduced `generateEtagFromTables` with a `source` parameter for flexibility in determining the source of truth. This update prepares the app for future scalability and allows switching between different ETag generation strategies. --- .../Http/Middleware/Etag/HandleEtag.php | 92 ++++++++++++++----- .../Http/Resources/Caching/EtagManager.php | 27 ++++++ routes/v1_1/api.php | 1 + 3 files changed, 97 insertions(+), 23 deletions(-) diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index 3dca9e62f2..fe51c6d35e 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -21,40 +21,36 @@ public function handle(Request $request, Closure $next): Response return $next($request); } - // Handle response. - $response = $next($request); + // Check if specific tables are defined for the route and calculate ETag. + $etag = $this->generateEtagFromTablesIfNeeded($request); - // Check if the response is cacheable. - if (!$this->isCacheableResponse($response)) { - return $response; // Skip ETag for non-cacheable responses. - } + // If the client has a matching ETag, return a 304 response. + // Otherwise, continue with the controller execution. + $response = $etag && $this->etagMatchesRequest($etag, $request) + ? $this->buildNotModifiedResponse($etag) + : $next($request); - // Generate ETag for the response. - $etag = EtagManager::getEtag($request, $response); + // Add the pre-calculated ETag to the response if available. if ($etag) { - // Set the ETag header. - $response->setEtag($etag); + $response = $this->setEtagOnResponse($response, $etag); + } - // Get and strip weak ETags from request headers. - $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + // If no ETag was calculated from tables, generate it based on the response. + if (!$etag && $this->isCacheableResponse($response)) { + $etag = EtagManager::getEtag($request, $response); + if ($etag) { + $response = $this->setEtagOnResponse($response, $etag); - // Compare ETags and set response as not modified if applicable. - if (in_array($etag, $noneMatch)) { - $response->setNotModified(); + // If the client has a matching ETag, set the response to 304. + if ($this->etagMatchesRequest($etag, $request)) { + $response = $this->buildNotModifiedResponse($etag); + } } } return $response; } - /** - * Remove the weak indicator (W/) from an ETag. - */ - private function stripWeakTags(string $etag): string - { - return str_replace('W/', '', $etag); - } - /** * Determine if a response is cacheable. */ @@ -67,4 +63,54 @@ private function isCacheableResponse(Response $response): bool return in_array($response->getStatusCode(), $cacheableStatusCodes) && !str_contains($cacheControl, 'no-store'); } + + /** + * Generate an ETag based on the tables defined in the route, if applicable. + */ + private function generateEtagFromTablesIfNeeded(Request $request): ?string + { + $tables = $request->route()->defaults['etag_tables'] ?? null; + + return $tables ? EtagManager::generateEtagFromTables(explode(',', $tables)) : null; + } + + /** + * Check if the ETag matches the request. + */ + private function etagMatchesRequest(string $etag, Request $request): bool + { + $noneMatch = array_map([$this, 'stripWeakTags'], $request->getETags()); + + return in_array($etag, $noneMatch); + } + + /** + * Build a 304 Not Modified response with the given ETag. + */ + private function buildNotModifiedResponse(string $etag): Response + { + $response = new Response(); + $response->setNotModified(); + $response->setEtag($etag); + + return $response; + } + + /** + * Set the ETag on a given response. + */ + private function setEtagOnResponse(Response $response, string $etag): Response + { + $response->setEtag($etag); + + return $response; + } + + /** + * Remove the weak indicator (W/) from an ETag. + */ + private function stripWeakTags(string $etag): string + { + return str_replace('W/', '', $etag); + } } diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php index 5344df06a0..edd6a822b6 100644 --- a/ProcessMaker/Http/Resources/Caching/EtagManager.php +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -4,6 +4,8 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Str; use Symfony\Component\HttpFoundation\Response; @@ -41,4 +43,29 @@ private static function defaultGetEtag(Response $response): string { return md5(auth()->id() . $response->getContent()); } + + /** + * Generate an ETag based on the latest update timestamps from multiple tables. + */ + public static function generateEtagFromTables(array $tables, string $source = 'updated_at'): string + { + // Fetch the latest update timestamp from each table. + // If the source is 'etag_version', use a cached version key as the source of truth. + $lastUpdated = collect($tables)->map(function ($table) use ($source) { + if ($source === 'etag_version') { + // This is not currently implemented but serves as a placeholder for future flexibility. + // The idea is to use a cached version key (e.g., "etag_version_table_name") as the source of truth. + // This would allow us to version the ETag dynamically and invalidate it using model observers or other mechanisms. + // If implemented, observers can increment this version key whenever the corresponding table is updated. + return Cache::get("etag_version_{$table}", 0); + } + + // Default to the updated_at column in the database. + return DB::table($table)->max('updated_at'); + })->max(); + + $etag = md5(auth()->id() . $lastUpdated); + + return (string) Str::of($etag)->start('"')->finish('"'); + } } diff --git a/routes/v1_1/api.php b/routes/v1_1/api.php index a2b2328dc9..70ed572f5c 100644 --- a/routes/v1_1/api.php +++ b/routes/v1_1/api.php @@ -22,6 +22,7 @@ // Route to show the screen of a task Route::get('/{taskId}/screen', [TaskController::class, 'showScreen']) + ->defaults('etag_tables', 'screens,screen_versions') ->name('show.screen'); // Route to show the interstitial screen of a task From cdaa80d2b04e0be00d4fc9bbc349c8bb745381a3 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Fri, 6 Dec 2024 09:38:34 -0600 Subject: [PATCH 12/19] add ETag middleware to startProcesses route - Applied ETag middleware to the 'startProcesses' route for improved caching and reduced payload size. - Added default 'etag_tables' parameter set to 'processes' to optimize ETag generation for this route. --- routes/engine.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/routes/engine.php b/routes/engine.php index 92f67def38..6ca0557b18 100644 --- a/routes/engine.php +++ b/routes/engine.php @@ -8,7 +8,10 @@ // Engine Route::prefix('api/1.0')->name('api.')->group(function () { // List of Processes that the user can start - Route::get('start_processes', [ProcessController::class, 'startProcesses'])->name('processes.start'); // Filtered in controller + Route::get('start_processes', [ProcessController::class, 'startProcesses']) + ->middleware('etag') + ->defaults('etag_tables', 'processes') + ->name('processes.start'); // Filtered in controller // Start a process Route::post('process_events/{process}', [ProcessController::class, 'triggerStartEvent'])->name('process_events.trigger')->middleware('can:start,process'); From da5ba5d70addffba635bdee0fc9161ac54ebd8d9 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 10 Dec 2024 13:44:10 -0600 Subject: [PATCH 13/19] Add detailed ETag implementation and testing documentation - Includes an overview of ETag functionality and benefits. - Provides implementation details for the HandleEtag middleware. - Covers ETag generation based on tables and response content. - Adds examples for conditional responses and route configuration. - Documents unit and manual testing approaches for ETag functionality. - Suggests future improvements for ETag caching and metrics collection. --- docs/etag-caching.md | 143 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 docs/etag-caching.md diff --git a/docs/etag-caching.md b/docs/etag-caching.md new file mode 100644 index 0000000000..a3aaa5498d --- /dev/null +++ b/docs/etag-caching.md @@ -0,0 +1,143 @@ +# ETag Documentation + +## Introduction + +### Description +An ETag (Entity Tag) is a unique fingerprint for a server response, similar to a hash based on the response content. If the content remains unchanged, the ETag stays the same. + +- **How it works**: + - When a browser requests content, it sends the previous ETag. + - If the server’s ETag matches, it responds with a `304 Not Modified` status, signaling no changes. + - This allows the browser to reuse its cached copy, saving time and bandwidth. + +### Purpose of Implementing ETag +The goal of implementing ETag is to enhance application performance by avoiding reloading unchanged content. The browser stores a copy, and if confirmed valid by the server, no new download is required. + +### Expected Benefits +- **Enhanced performance**: Reduces server load by avoiding processing unchanged content. +- **Bandwidth savings**: Cached content is reused. +- **Increased speed**: `304 Not Modified` responses are faster than resending content. +- **Resource optimization**: Lowers server CPU and memory consumption. + +### Middleware Overview +The `HandleEtag` middleware manages ETags in HTTP responses to optimize requests through conditional responses (`304 Not Modified`), reducing server load and bandwidth usage. + +## Implementation + +### Middleware Setup +The middleware is applied globally to API `GET` routes and can be customized for specific routes via table configurations (`etag_tables`). + +#### 1. HandleEtag Middleware +The `HandleEtag` middleware performs key tasks: +1. Applies ETag functionality to `GET` requests only. +2. Generates the ETag: + - Based on related tables if configured (optional). + - Based on the response content when no tables are specified (default). +3. Validates the client’s `If-None-Match` header. If it matches the calculated ETag, responds with a `304 Not Modified` status. +4. Ensures HTTP responses include the ETag header. + +#### 2. ETag Generation +ETag generation is handled dynamically via the `EtagManager` class, using two methods: + +**Based on Tables** +ETag is calculated using the `updated_at` timestamp of specified tables: +```php +public static function generateEtagFromTables(array $tables, string $source = 'updated_at'): string +{ + $lastUpdated = collect($tables)->map(function ($table) use ($source) { + return DB::table($table)->max($source); + })->max(); + + return md5(auth()->id() . $lastUpdated); +} +``` + +**Based on Response** +ETag is generated from the response content: +```php +public static function getEtag(Request $request, Response $response): string +{ + return md5(auth()->id() . $response->getContent()); +} +``` + +#### 3. Conditional Responses +The middleware handles conditional responses by checking the client’s `If-None-Match` header: +1. If the calculated ETag matches the client’s value, responds with `304 Not Modified`. +2. Otherwise, generates new content and includes a new ETag in the response. + +#### 4. Middleware Configuration +The `HandleEtag` middleware is registered globally within the API group: +```php +// ProcessMaker/Providers/ProcessMakerServiceProvider.php +public function boot(): void +{ + Route::pushMiddlewareToGroup('api', HandleEtag::class); +} +``` + +Routes in `v1_1` can be mapped as follows: +```php +protected function mapApiRoutes() +{ + Route::middleware(['auth:api', 'etag']) + ->group(base_path('routes/v1_1/api.php')); +} +``` + +#### 5. Route Usage +Routes can define custom settings, such as associating ETags with database tables: +```php +Route::prefix('api/1.0')->name('api.')->group(function () { + Route::get('start_processes', [ProcessController::class, 'startProcesses']) + ->middleware('etag') // Apply middleware + ->defaults('etag_tables', 'processes') // Define related tables + ->name('processes.start'); +}); +``` + +In this example: +- Middleware generates the ETag based on the last update of the `processes` table. +- If the client has the corresponding ETag, the server responds with `304 Not Modified` and does not execute the controller logic. + +## Testing + +### Unit Tests +Unit tests ensure proper functionality of the `HandleEtag` middleware and `EtagManager` class: +- **Middleware Tests**: + - Ensures the `ETag` header is added correctly. + - Validates `If-None-Match` requests with appropriate status codes (`200 OK` or `304 Not Modified`). + - Handles weak ETags and generates custom ETags. +- **EtagManager Tests**: + - Validates ETag generation using: + - Default response content. + - Custom callbacks (e.g., `md5`, `sha256`). + - Dynamic data such as database tables. + +### Manual Tests +Use tools like Postman or browser developer tools for manual testing: +1. **Verify ETag header generation**: + - Make a `GET` request to an API route. + - Check the response headers for a valid ETag value. +2. **Validate `If-None-Match` behavior**: + - Send a `GET` request with the previous ETag. + - Verify response status (`304 Not Modified` if unchanged, `200 OK` if changed). + +### Example Scenarios +- Test routes using `etag_tables`: + - Change data in related tables and observe changes in the ETag header. + +## Conclusion + +This implementation leverages ETags to optimize `GET` requests in the API, reducing bandwidth usage and improving performance. Middleware flexibility allows ETag customization based on specific tables or response content. + +### Future Improvements +1. **ETag Versioning with Cache**: + - Store ETag versions in cache to avoid database queries. + - Enable manual invalidation via model events. +2. **Configuration File**: + - Add a `config/etag.php` for global middleware control. +3. **Metrics Collection**: + - Monitor request duration, `304` response percentage and bandwidth savings. + +This solution is well-suited for global optimizations and specific dynamic routes. From 33fe4f68394d85b1963bba92d008db604d84556e Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Wed, 11 Dec 2024 13:13:52 -0600 Subject: [PATCH 14/19] Add ETag cache invalidation tests - Introduced a new test class `HandleEtagCacheInvalidationTest` to verify ETag behavior upon database updates. - Implemented tests to ensure ETag changes when the underlying data is modified and that the correct ETag is returned for subsequent requests. - Updated existing `HandleEtagTest` to include a test for returning 304 Not Modified when the ETag matches the client-provided value. --- .../Http/Resources/Caching/EtagManager.php | 10 +-- .../Etag/HandleEtagCacheInvalidationTest.php | 65 +++++++++++++++++++ tests/Feature/Etag/HandleEtagTest.php | 33 +++++++++- 3 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 tests/Feature/Etag/HandleEtagCacheInvalidationTest.php diff --git a/ProcessMaker/Http/Resources/Caching/EtagManager.php b/ProcessMaker/Http/Resources/Caching/EtagManager.php index edd6a822b6..71d41ad950 100644 --- a/ProcessMaker/Http/Resources/Caching/EtagManager.php +++ b/ProcessMaker/Http/Resources/Caching/EtagManager.php @@ -53,10 +53,12 @@ public static function generateEtagFromTables(array $tables, string $source = 'u // If the source is 'etag_version', use a cached version key as the source of truth. $lastUpdated = collect($tables)->map(function ($table) use ($source) { if ($source === 'etag_version') { - // This is not currently implemented but serves as a placeholder for future flexibility. - // The idea is to use a cached version key (e.g., "etag_version_table_name") as the source of truth. - // This would allow us to version the ETag dynamically and invalidate it using model observers or other mechanisms. - // If implemented, observers can increment this version key whenever the corresponding table is updated. + /** + * This is not currently implemented but serves as a placeholder for future flexibility. + * The idea is to use a cached version key (e.g., "etag_version_table_name") as the source of truth. + * This would allow us to version the ETag dynamically and invalidate it using model observers or other mechanisms. + * If implemented, observers can increment this version key whenever the corresponding table is updated. + */ return Cache::get("etag_version_{$table}", 0); } diff --git a/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php b/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php new file mode 100644 index 0000000000..f9a3663c61 --- /dev/null +++ b/tests/Feature/Etag/HandleEtagCacheInvalidationTest.php @@ -0,0 +1,65 @@ +get(self::TEST_ROUTE, function () { + return response($this->response, 200); + }) + ->defaults('etag_tables', 'processes'); + } + + public function testEtagInvalidatesOnDatabaseUpdate() + { + $user = User::factory()->create(); + $this->actingAs($user); + + // Create a process record to simulate database changes. + $process = Process::factory()->create([ + 'updated_at' => now()->yesterday(), + ]); + + // First request: Get the initial ETag. + $response = $this->get(self::TEST_ROUTE); + $initialEtag = $response->headers->get('ETag'); + $this->assertNotNull($initialEtag, 'Initial ETag was set'); + + // Simulate a database update by changing `updated_at`. + $process->update(['name' => $this->faker->name]); + + // Second request: ETag should change due to the database update. + $responseAfterUpdate = $this->get(self::TEST_ROUTE); + $newEtag = $responseAfterUpdate->headers->get('ETag'); + + $this->assertNotNull($newEtag, 'New ETag was set after database update'); + $this->assertNotEquals($initialEtag, $newEtag, 'ETag changed after database update'); + + // Third request: Simulate a client sending the old ETag. + $responseWithOldEtag = $this->withHeaders(['If-None-Match' => $initialEtag]) + ->get(self::TEST_ROUTE); + + $responseWithOldEtag->assertStatus(200); + $responseWithOldEtag->assertHeader('ETag', $newEtag, 'Response did not return the updated ETag'); + } +} diff --git a/tests/Feature/Etag/HandleEtagTest.php b/tests/Feature/Etag/HandleEtagTest.php index e933260f04..af9c731e29 100644 --- a/tests/Feature/Etag/HandleEtagTest.php +++ b/tests/Feature/Etag/HandleEtagTest.php @@ -3,8 +3,7 @@ namespace ProcessMaker\Tests\Feature\Etag; use Illuminate\Support\Facades\Route; -use ProcessMaker\Http\Middleware\Etag\HandleEtag; -use ProcessMaker\Http\Resources\Caching\EtagManager; +use ProcessMaker\Models\Process; use ProcessMaker\Models\User; use Tests\TestCase; @@ -19,7 +18,7 @@ public function setUp(): void parent::setUp(); // Define a route that uses the HandleEtag middleware. - Route::middleware(HandleEtag::class)->any(self::TEST_ROUTE, function () { + Route::middleware('etag')->any(self::TEST_ROUTE, function () { return response($this->response, 200); }); } @@ -84,4 +83,32 @@ public function testDefaultGetEtagGeneratesCorrectEtagWithUser() $expectedEtag = '"' . md5($user->id . $this->response) . '"'; $response->assertHeader('ETag', $expectedEtag); } + + public function testReturns304NotModifiedWhenEtagMatchesTables() + { + $user = User::factory()->create(); + $this->actingAs($user); + + Process::factory()->create([ + 'updated_at' => now()->yesterday(), + ]); + + Route::middleware('etag')->any(self::TEST_ROUTE, function () { + return response($this->response, 200); + })->defaults('etag_tables', 'processes'); + + // Initial request to get the ETAg. + $response = $this->get(self::TEST_ROUTE); + $etag = $response->headers->get('ETag'); + $this->assertNotNull($etag, 'ETag should be set in the initial response'); + + // Perform a second request sending the `If-None-Match`. + $responseWithMatchingEtag = $this->withHeaders(['If-None-Match' => $etag]) + ->get(self::TEST_ROUTE); + + // Verify response is 304 Not Modified. + $responseWithMatchingEtag->assertStatus(304); + $this->assertEmpty($responseWithMatchingEtag->getContent(), 'Response content is not empty for 304 Not Modified'); + $this->assertEquals($etag, $responseWithMatchingEtag->headers->get('ETag'), 'ETag does not match the client-provided If-None-Match'); + } } From 428b0ee3823d7825c189fd6ab52ff31f6adb4a97 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Thu, 12 Dec 2024 10:32:24 -0600 Subject: [PATCH 15/19] Enhance ETag middleware to log dynamic endpoint detection - Introduced logging for highly dynamic endpoints by tracking ETag history for each endpoint. - Implemented a mechanism to limit the number of tracked ETags and log when all tracked ETags are unique. - Updated documentation to reflect the new logging feature and its implications for performance optimization. --- .../Http/Middleware/Etag/HandleEtag.php | 47 +++++++++++++++++++ docs/etag-caching.md | 19 ++++++++ 2 files changed, 66 insertions(+) diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index fe51c6d35e..bbfa8a2e69 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -4,6 +4,8 @@ use Closure; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; use ProcessMaker\Http\Resources\Caching\EtagManager; use Symfony\Component\HttpFoundation\Response; @@ -11,6 +13,10 @@ class HandleEtag { public string $middleware = 'etag'; + private const ETAG_HISTORY_LIMIT = 10; // Limit of ETags to track per endpoint. + + private const CACHE_EXPIRATION_MINUTES = 30; // Cache expiration time in minutes. + /** * Handle an incoming request. */ @@ -48,6 +54,9 @@ public function handle(Request $request, Closure $next): Response } } + // Detect if the ETag changes frequently for dynamic responses. + $this->logEtagChanges($request, $etag); + return $response; } @@ -113,4 +122,42 @@ private function stripWeakTags(string $etag): string { return str_replace('W/', '', $etag); } + + /** + * Log ETag changes to detect highly dynamic responses. + */ + private function logEtagChanges(Request $request, ?string $etag): void + { + if (!$etag) { + return; + } + + // Retrieve the history of ETags for this endpoint. + $url = $request->fullUrl(); + $cacheKey = 'etag_history:' . md5($url); + $etagHistory = Cache::get($cacheKey, []); + + // If the ETag is already in the history, it is not considered dynamic. + if (in_array($etag, $etagHistory, true)) { + return; + } + + // Add the new ETag to the history. + $etagHistory[] = $etag; + + // Keep the history limited to the last n ETags. + if (count($etagHistory) > self::ETAG_HISTORY_LIMIT) { + array_shift($etagHistory); // Remove the oldest ETag. + } + + // Save the updated history in the cache, valid for 30 minutes. + Cache::put($cacheKey, $etagHistory, now()->addMinutes(self::CACHE_EXPIRATION_MINUTES)); + + // If the history is full and all ETags are unique, log this as a highly dynamic endpoint. + if (count(array_unique($etagHistory)) === self::ETAG_HISTORY_LIMIT) { + Log::info('ETag Dynamic endpoint detected', [ + 'url' => $url, + ]); + } + } } diff --git a/docs/etag-caching.md b/docs/etag-caching.md index a3aaa5498d..3d352864d6 100644 --- a/docs/etag-caching.md +++ b/docs/etag-caching.md @@ -100,6 +100,25 @@ In this example: - Middleware generates the ETag based on the last update of the `processes` table. - If the client has the corresponding ETag, the server responds with `304 Not Modified` and does not execute the controller logic. +## Logs + +This middleware detects **highly dynamic endpoints** by tracking the history of ETags generated for each endpoint. It helps identify endpoints where ETags are consistently different, indicating dynamic responses that may require further optimization. + +1. Tracks the last **N ETags** (default: 10) for each endpoint using Laravel's cache. +2. Logs endpoints as "highly dynamic" if all tracked ETags are unique. +3. Efficient caching and memory usage to minimize performance overhead. + +### Example Logs + +When an endpoint is identified as highly dynamic, the following log is generated: + +``` +ETag Dynamic endpoint detected: +{ + "url": "https://example.com/api/resource", +} +``` + ## Testing ### Unit Tests From f28fcb54ff40835b94ff37c568227afa9874dd6b Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Mon, 16 Dec 2024 11:10:16 -0600 Subject: [PATCH 16/19] Add configuration support for ETag logging and caching - Integrated `config/etag.php` for dynamic configuration of ETag functionality. - Added `enabled` and `log_dynamic_endpoints` flags to control feature behavior. This update improves flexibility and allows disabling ETag processing entirely when `enabled` is set to false. --- .../Http/Middleware/Etag/HandleEtag.php | 20 +++++--- config/etag.php | 47 +++++++++++++++++++ docs/etag-caching.md | 14 ++++-- 3 files changed, 71 insertions(+), 10 deletions(-) create mode 100644 config/etag.php diff --git a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php index bbfa8a2e69..5425e0e6da 100644 --- a/ProcessMaker/Http/Middleware/Etag/HandleEtag.php +++ b/ProcessMaker/Http/Middleware/Etag/HandleEtag.php @@ -13,15 +13,15 @@ class HandleEtag { public string $middleware = 'etag'; - private const ETAG_HISTORY_LIMIT = 10; // Limit of ETags to track per endpoint. - - private const CACHE_EXPIRATION_MINUTES = 30; // Cache expiration time in minutes. - /** * Handle an incoming request. */ public function handle(Request $request, Closure $next): Response { + if (!config('etag.enabled')) { + return $next($request); + } + // Process only GET and HEAD methods. if (!$request->isMethod('GET') && !$request->isMethod('HEAD')) { return $next($request); @@ -128,6 +128,10 @@ private function stripWeakTags(string $etag): string */ private function logEtagChanges(Request $request, ?string $etag): void { + if (!config('etag.enabled') || !config('etag.log_dynamic_endpoints')) { + return; + } + if (!$etag) { return; } @@ -146,15 +150,17 @@ private function logEtagChanges(Request $request, ?string $etag): void $etagHistory[] = $etag; // Keep the history limited to the last n ETags. - if (count($etagHistory) > self::ETAG_HISTORY_LIMIT) { + $etagHistoryLimit = config('etag.history_limit', 10); + if (count($etagHistory) > $etagHistoryLimit) { array_shift($etagHistory); // Remove the oldest ETag. } // Save the updated history in the cache, valid for 30 minutes. - Cache::put($cacheKey, $etagHistory, now()->addMinutes(self::CACHE_EXPIRATION_MINUTES)); + $cacheExpirationMinute = config('etag.history_cache_expiration'); + Cache::put($cacheKey, $etagHistory, now()->addMinutes($cacheExpirationMinute)); // If the history is full and all ETags are unique, log this as a highly dynamic endpoint. - if (count(array_unique($etagHistory)) === self::ETAG_HISTORY_LIMIT) { + if (count(array_unique($etagHistory)) === $etagHistoryLimit) { Log::info('ETag Dynamic endpoint detected', [ 'url' => $url, ]); diff --git a/config/etag.php b/config/etag.php new file mode 100644 index 0000000000..b4e016f8db --- /dev/null +++ b/config/etag.php @@ -0,0 +1,47 @@ + env('ETAG_ENABLED', true), + + /* + |-------------------------------------------------------------------------- + | Log Dynamic Endpoints + |-------------------------------------------------------------------------- + | + | Enable or disable logging when an endpoint is detected as dynamic. + | If set to false, no logs will be recorded for dynamic endpoints. + | + */ + 'log_dynamic_endpoints' => env('ETAG_LOG_DYNAMIC_ENDPOINTS', false), + + /* + |-------------------------------------------------------------------------- + | ETag History Limit + |-------------------------------------------------------------------------- + | + | The maximum number of ETags to track per endpoint. If the number of + | unique ETags exceeds this limit, the oldest ETag will be removed. + | + */ + 'history_limit' => env('ETAG_HISTORY_LIMIT', 10), + + /* + |-------------------------------------------------------------------------- + | History Cache Expiration Time + |-------------------------------------------------------------------------- + | + | The duration (in minutes) for which the ETag history should be stored + | in the cache. Adjust this based on your caching strategy. + | + */ + 'history_cache_expiration' => env('ETAG_HISTORY_CACHE_EXPIRATION_MINUTES', 30), +]; diff --git a/docs/etag-caching.md b/docs/etag-caching.md index 3d352864d6..4ee52a2549 100644 --- a/docs/etag-caching.md +++ b/docs/etag-caching.md @@ -100,6 +100,16 @@ In this example: - Middleware generates the ETag based on the last update of the `processes` table. - If the client has the corresponding ETag, the server responds with `304 Not Modified` and does not execute the controller logic. +## Config file + +The ETag functionality is managed through the `config/etag.php` file, which centralizes all related settings. This configuration file allows you to enable or disable ETag logging and caching, as well as customize key parameters such as history limits and cache expiration times. + +### Key Options +- **`enabled`**: Determines whether the ETag functionality is active. If set to `false`, all ETag-related processing is skipped. +- **`log_dynamic_endpoints`**: Controls whether dynamic endpoints are logged. When disabled, no cache processing occurs. +- **`history_limit`**: Specifies the maximum number of ETags to track per endpoint. +- **`cache_expiration`**: Sets the duration (in minutes) for which the ETag history is cached. + ## Logs This middleware detects **highly dynamic endpoints** by tracking the history of ETags generated for each endpoint. It helps identify endpoints where ETags are consistently different, indicating dynamic responses that may require further optimization. @@ -154,9 +164,7 @@ This implementation leverages ETags to optimize `GET` requests in the API, reduc 1. **ETag Versioning with Cache**: - Store ETag versions in cache to avoid database queries. - Enable manual invalidation via model events. -2. **Configuration File**: - - Add a `config/etag.php` for global middleware control. -3. **Metrics Collection**: +2. **Metrics Collection**: - Monitor request duration, `304` response percentage and bandwidth savings. This solution is well-suited for global optimizations and specific dynamic routes. From 4e7afdc75c7501c004da89e3608398d4ad29acc3 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 17 Dec 2024 08:25:57 -0600 Subject: [PATCH 17/19] Add ETag response time comparison for 200 vs 304 - Added custom Trend metrics to measure and compare durations of 200 OK and 304 Not Modified responses - Validates that 304 responses are faster using If-None-Match header - Improved test clarity by focusing on ETag performance under load --- tests/k6/etag/performance-test.js | 48 +++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/k6/etag/performance-test.js diff --git a/tests/k6/etag/performance-test.js b/tests/k6/etag/performance-test.js new file mode 100644 index 0000000000..21f40cac4a --- /dev/null +++ b/tests/k6/etag/performance-test.js @@ -0,0 +1,48 @@ +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Trend } from 'k6/metrics'; + +// Custom metrics to track request durations. +let duration200 = new Trend('duration_200'); +let duration304 = new Trend('duration_304'); + +export let options = { + stages: [ + { duration: '10s', target: 10 }, + { duration: '20s', target: 50 }, + { duration: '10s', target: 0 }, + ], +}; + +export default function () { + const baseUrl = 'https://processmaker.test/api/1.0/start_processes?page=1&per_page=15&filter=&order_by=category.name%2Cname&order_direction=asc%2Casc&include=events%2Ccategories&without_event_definitions=true'; + const token = 'fake-jwt'; // Replace with your actual token + const headers = { Authorization: `Bearer ${token}` }; + + group('ETag Performance', () => { + // Add the duration of the 200 response to the custom metric. + let res = http.get(baseUrl, { headers }); + duration200.add(res.timings.duration); + check(res, { + 'status is 200': (r) => r.status === 200, + }); + + // Use ETag with If-None-Match header to validate 304 response. + const etag = res.headers.Etag; + if (etag) { + const conditionalHeaders = { + ...headers, + 'If-None-Match': etag, + }; + + // Add the duration of the 304 response to the custom metric + let conditionalRes = http.get(baseUrl, { headers: conditionalHeaders }); + duration304.add(conditionalRes.timings.duration); + check(conditionalRes, { + 'status is 304': (r) => r.status === 304, + }); + } + + sleep(1); + }); +} \ No newline at end of file From d590373592d4096bb8dcc2e18555cb753b2e9ad1 Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 17 Dec 2024 08:33:43 -0600 Subject: [PATCH 18/19] Add ETag history tracking to detect dynamic endpoints - Implemented tracking of ETag values for specified endpoints - Added logic to identify dynamic endpoints when ETag history shows diff values --- tests/k6/etag/detect-dynamic-endpoints.js | 61 +++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 tests/k6/etag/detect-dynamic-endpoints.js diff --git a/tests/k6/etag/detect-dynamic-endpoints.js b/tests/k6/etag/detect-dynamic-endpoints.js new file mode 100644 index 0000000000..1b48ff5191 --- /dev/null +++ b/tests/k6/etag/detect-dynamic-endpoints.js @@ -0,0 +1,61 @@ +import http from 'k6/http'; + +export let options = { + vus: 1, + iterations: 5, // n iterations per URL. +}; + +const token = 'fake-jwt'; +const endpoints = [ + 'https://processmaker.test/api/1.0/requests?page=1&per_page=15&include=process%2Cparticipants%2CactiveTasks%2Cdata&pmql=%28requester%20%3D%20%22admin%22%29&filter=&order_by=id&order_direction=DESC&advanced_filter=%5B%7B%22subject%22%3A%7B%22type%22%3A%22Status%22%7D%2C%22operator%22%3A%22%3D%22%2C%22value%22%3A%22In%20Progress%22%7D%5D' +]; + +// Object to track ETag history for each endpoint. +const etagHistory = {}; + +// Limit to determine when an endpoint is considered dynamic. +const ETAG_HISTORY_LIMIT = 5; + +export default function () { + const headers = { + Authorization: `Bearer ${token}`, + }; + + endpoints.forEach((url) => { + const res1 = http.get(url, { headers }); + const etag = res1.headers['Etag']; + + // If no ETag is present, log a warning and skip further processing. + if (!etag) { + console.log(`No ETag found for ${url}`); + return; + } + + // Log the ETag value for debugging. + console.log(`ETag: ${etag}`); + + // Initialize the ETag history for this endpoint if not already present. + if (!etagHistory[url]) { + etagHistory[url] = []; + } + + // Add the ETag to the history if it is unique. + if (!etagHistory[url].includes(etag)) { + etagHistory[url].push(etag); + } + + // Keep the history limited to the last N ETags. + if (etagHistory[url].length > ETAG_HISTORY_LIMIT) { + etagHistory[url].shift(); // Remove the oldest ETag + } + + // Check if the endpoint is dynamic. + // If the history is full and all ETags are unique, the endpoint is considered dynamic. + if ( + etagHistory[url].length === ETAG_HISTORY_LIMIT && + new Set(etagHistory[url]).size === ETAG_HISTORY_LIMIT + ) { + console.log(`Dynamic endpoint detected: ${url}`); + } + }); +} \ No newline at end of file From 8086a78951f8e76d761364328d5d680853c4017d Mon Sep 17 00:00:00 2001 From: Eleazar Resendez Date: Tue, 17 Dec 2024 09:37:44 -0600 Subject: [PATCH 19/19] Add long-duration test to validate ETag stability under sustained load --- tests/k6/etag/config.js | 11 ++++++ tests/k6/etag/detect-dynamic-endpoints.js | 8 ++--- tests/k6/etag/long-duration-test.js | 43 +++++++++++++++++++++++ tests/k6/etag/performance-test.js | 8 ++--- 4 files changed, 59 insertions(+), 11 deletions(-) create mode 100644 tests/k6/etag/config.js create mode 100644 tests/k6/etag/long-duration-test.js diff --git a/tests/k6/etag/config.js b/tests/k6/etag/config.js new file mode 100644 index 0000000000..843d73bcc4 --- /dev/null +++ b/tests/k6/etag/config.js @@ -0,0 +1,11 @@ +export const config = { + token: 'FAKE_JWT', // Replace with a valid token. + endpoints: { + startProcesses: 'https://processmaker.test/api/1.0/start_processes?page=1&per_page=15&filter=&order_by=category.name%2Cname&order_direction=asc%2Casc&include=events%2Ccategories&without_event_definitions=true', + }, +}; + +export const headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.token}`, +}; \ No newline at end of file diff --git a/tests/k6/etag/detect-dynamic-endpoints.js b/tests/k6/etag/detect-dynamic-endpoints.js index 1b48ff5191..091a3722a1 100644 --- a/tests/k6/etag/detect-dynamic-endpoints.js +++ b/tests/k6/etag/detect-dynamic-endpoints.js @@ -1,13 +1,13 @@ import http from 'k6/http'; +import { config, headers } from './config.js'; export let options = { vus: 1, iterations: 5, // n iterations per URL. }; -const token = 'fake-jwt'; const endpoints = [ - 'https://processmaker.test/api/1.0/requests?page=1&per_page=15&include=process%2Cparticipants%2CactiveTasks%2Cdata&pmql=%28requester%20%3D%20%22admin%22%29&filter=&order_by=id&order_direction=DESC&advanced_filter=%5B%7B%22subject%22%3A%7B%22type%22%3A%22Status%22%7D%2C%22operator%22%3A%22%3D%22%2C%22value%22%3A%22In%20Progress%22%7D%5D' + config.endpoints.startProcesses, ]; // Object to track ETag history for each endpoint. @@ -17,10 +17,6 @@ const etagHistory = {}; const ETAG_HISTORY_LIMIT = 5; export default function () { - const headers = { - Authorization: `Bearer ${token}`, - }; - endpoints.forEach((url) => { const res1 = http.get(url, { headers }); const etag = res1.headers['Etag']; diff --git a/tests/k6/etag/long-duration-test.js b/tests/k6/etag/long-duration-test.js new file mode 100644 index 0000000000..5ed1651568 --- /dev/null +++ b/tests/k6/etag/long-duration-test.js @@ -0,0 +1,43 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; +import { Trend } from 'k6/metrics'; +import { config, headers } from './config.js'; + +// Metrics to track response durations. +let duration200 = new Trend('duration_200'); +let duration304 = new Trend('duration_304'); + +export let options = { + stages: [ + { duration: '5m', target: 50 }, // Hold 50 VUs for 5 minutes. + ], + thresholds: { + http_req_duration: ['p(95)<1000'], // 95% of requests should respond in < 1s. + duration_200: ['avg<800'], // Ensure 200 OK average duration is < 800ms. + duration_304: ['avg<400'], // Ensure 304 Not Modified avg duration is < 400ms. + }, +}; + +export default function () { + const url = config.endpoints.startProcesses; + let res = http.get(url, { headers }); + duration200.add(res.timings.duration); + check(res, { + 'status is 200': (r) => r.status === 200, + 'ETag exists': (r) => !!r.headers.Etag, + }); + + const etag = res.headers.Etag; + if (etag) { + const conditionalHeaders = { ...headers, 'If-None-Match': etag }; + + let conditionalRes = http.get(url, { headers: conditionalHeaders }); + duration304.add(conditionalRes.timings.duration); + + check(conditionalRes, { + 'status is 304': (r) => r.status === 304, + }); + } + + sleep(1); +} \ No newline at end of file diff --git a/tests/k6/etag/performance-test.js b/tests/k6/etag/performance-test.js index 21f40cac4a..0805d98ceb 100644 --- a/tests/k6/etag/performance-test.js +++ b/tests/k6/etag/performance-test.js @@ -1,6 +1,7 @@ import http from 'k6/http'; import { check, sleep, group } from 'k6'; import { Trend } from 'k6/metrics'; +import { config, headers } from './config.js'; // Custom metrics to track request durations. let duration200 = new Trend('duration_200'); @@ -15,10 +16,7 @@ export let options = { }; export default function () { - const baseUrl = 'https://processmaker.test/api/1.0/start_processes?page=1&per_page=15&filter=&order_by=category.name%2Cname&order_direction=asc%2Casc&include=events%2Ccategories&without_event_definitions=true'; - const token = 'fake-jwt'; // Replace with your actual token - const headers = { Authorization: `Bearer ${token}` }; - + const baseUrl = config.endpoints.startProcesses; group('ETag Performance', () => { // Add the duration of the 200 response to the custom metric. let res = http.get(baseUrl, { headers }); @@ -42,7 +40,7 @@ export default function () { 'status is 304': (r) => r.status === 304, }); } - + sleep(1); }); } \ No newline at end of file