diff --git a/lib/Api/DevCycleClient.php b/lib/Api/DevCycleClient.php index 9e10b9c..26beb76 100644 --- a/lib/Api/DevCycleClient.php +++ b/lib/Api/DevCycleClient.php @@ -12,6 +12,10 @@ use DevCycle\Model\ErrorResponse; use DevCycle\Model\InlineResponse201; use DevCycle\Model\Variable; +use DevCycle\Model\EvalHookRunner; +use DevCycle\Model\HookContext; +use DevCycle\Model\BeforeHookError; +use DevCycle\Model\AfterHookError; use DevCycle\ObjectSerializer; use DevCycle\OpenFeature\DevCycleProvider; use Exception; @@ -59,6 +63,8 @@ class DevCycleClient private bool $usingOpenFeature; + protected EvalHookRunner $hookRunner; + /** * @param string $sdkKey * @param DevCycleOptions $dvcOptions @@ -84,6 +90,7 @@ public function __construct( $this->dvcOptions = $dvcOptions; $this->openFeatureProvider = new DevCycleProvider($this); $this->usingOpenFeature = false; + $this->hookRunner = new EvalHookRunner($dvcOptions->getHooks()); } public function getOpenFeatureProvider(): DevCycleProvider @@ -285,15 +292,64 @@ public function variable(DevCycleUser $user, string $key, mixed $default): Varia { $this->validateUserData($user); + // Create hook context + $context = new HookContext($user, $key, $default); + $hooks = $this->hookRunner->getHooks(); + $reversedHooks = array_reverse($hooks); + $beforeHookError = null; + $afterHookError = null; + $evaluationError = null; + $result = null; + try { - list($response) = $this->variableWithHttpInfo($user, $key); - return $this->reformatVariable($key, $response, $default); - } catch (GuzzleException|ApiException $e) { - if ($e->getCode() != 404) { + // Run before hooks + if (!empty($hooks)) { + try { + $this->hookRunner->runBeforeHooks($hooks, $context); + } catch (BeforeHookError $e) { + $beforeHookError = $e; + error_log("Before hook error: " . $e->getMessage()); + } + } + + try { + list($response) = $this->variableWithHttpInfo($user, $key); + $result = $this->reformatVariable($key, $response, $default); + $context->setVariableDetails($result); + } catch (GuzzleException|ApiException $e) { + $evaluationError = $e; + if ($e->getCode() != 404) { + error_log("Failed to get variable value for key $key, " . $e->getMessage()); + } + $result = new Variable(array("key" => $key, "value" => $default, "type" => gettype($default), "isDefaulted" => true)); + $context->setVariableDetails($result); + } - error_log("Failed to get variable value for key $key, " . $e->getMessage()); + // Run after hooks if no before hook error + if (!empty($hooks)) { + try { + $this->hookRunner->runAfterHooks($reversedHooks, $context); + } catch (AfterHookError $e) { + $afterHookError = $e; + error_log("After hook error: " . $e->getMessage()); + } + } + + return $result ?? new Variable(array("key" => $key, "value" => $default, "type" => gettype($default), "isDefaulted" => true)); + + } finally { + // Always run onFinally hooks + if (!empty($hooks)) { + $this->hookRunner->runOnFinallyHooks($reversedHooks, $context); + } + + // Run error hooks if any error occurred + if (!empty($hooks) && ($beforeHookError !== null || $afterHookError !== null || $evaluationError !== null)) { + $error = $beforeHookError ?? $afterHookError ?? $evaluationError; + if ($error !== null) { + $this->hookRunner->runErrorHooks($reversedHooks, $context, $error); + } } - return new Variable(array("key" => $key, "value" => $default, "type" => gettype($default), "isDefaulted" => true)); } } diff --git a/lib/Model/AfterHookError.php b/lib/Model/AfterHookError.php new file mode 100644 index 0000000..45e233b --- /dev/null +++ b/lib/Model/AfterHookError.php @@ -0,0 +1,28 @@ +enableEdgeDB = $enableEdgeDB; if ($bucketingApiHostname !== null) { @@ -33,6 +37,7 @@ public function __construct(bool $enableEdgeDB = false, ?string $bucketingApiHos } $this->unixSocketPath = $unixSocketPath; $this->httpOptions = $httpOptions; + $this->hooks = $hooks; } /** @@ -67,4 +72,14 @@ public function getHttpOptions(): array { return $this->httpOptions; } + + /** + * Gets the evaluation hooks + * + * @return array Array of evaluation hooks + */ + public function getHooks(): array + { + return $this->hooks; + } } diff --git a/lib/Model/EvalHook.php b/lib/Model/EvalHook.php new file mode 100644 index 0000000..358d743 --- /dev/null +++ b/lib/Model/EvalHook.php @@ -0,0 +1,95 @@ +before = $before; + $this->after = $after; + $this->onFinally = $onFinally; + $this->error = $error; + } + + /** + * Get the before callback + * + * @return callable|null + */ + public function getBefore(): ?callable + { + return $this->before; + } + + /** + * Get the after callback + * + * @return callable|null + */ + public function getAfter(): ?callable + { + return $this->after; + } + + /** + * Get the onFinally callback + * + * @return callable|null + */ + public function getOnFinally(): ?callable + { + return $this->onFinally; + } + + /** + * Get the error callback + * + * @return callable|null + */ + public function getError(): ?callable + { + return $this->error; + } +} diff --git a/lib/Model/EvalHookRunner.php b/lib/Model/EvalHookRunner.php new file mode 100644 index 0000000..06b85da --- /dev/null +++ b/lib/Model/EvalHookRunner.php @@ -0,0 +1,141 @@ +hooks = $hooks; + } + + /** + * Get all hooks + * + * @return EvalHook[] + */ + public function getHooks(): array + { + return $this->hooks; + } + + /** + * Add a hook + * + * @param EvalHook $hook The hook to add + * @return void + */ + public function addHook(EvalHook $hook): void + { + $this->hooks[] = $hook; + } + + /** + * Run before hooks + * + * @param EvalHook[] $hooks Array of hooks to run + * @param HookContext $context The hook context + * @throws BeforeHookError When a before hook fails + * @return void + */ + public function runBeforeHooks(array $hooks, HookContext $context): void + { + foreach ($hooks as $hook) { + $beforeCallback = $hook->getBefore(); + if ($beforeCallback !== null) { + try { + call_user_func($beforeCallback, $context); + } catch (Exception $e) { + throw new BeforeHookError("Before hook failed: " . $e->getMessage(), 0, $e); + } + } + } + } + + /** + * Run after hooks + * + * @param EvalHook[] $hooks Array of hooks to run + * @param HookContext $context The hook context + * @throws AfterHookError When an after hook fails + * @return void + */ + public function runAfterHooks(array $hooks, HookContext $context): void + { + foreach ($hooks as $hook) { + $afterCallback = $hook->getAfter(); + if ($afterCallback !== null) { + try { + call_user_func($afterCallback, $context); + } catch (Exception $e) { + throw new AfterHookError("After hook failed: " . $e->getMessage(), 0, $e); + } + } + } + } + + /** + * Run onFinally hooks + * + * @param EvalHook[] $hooks Array of hooks to run + * @param HookContext $context The hook context + * @return void + */ + public function runOnFinallyHooks(array $hooks, HookContext $context): void + { + foreach ($hooks as $hook) { + $onFinallyCallback = $hook->getOnFinally(); + if ($onFinallyCallback !== null) { + try { + call_user_func($onFinallyCallback, $context); + } catch (Exception $e) { + // Log the error but don't throw it + error_log("OnFinally hook failed: " . $e->getMessage()); + } + } + } + } + + /** + * Run error hooks + * + * @param EvalHook[] $hooks Array of hooks to run + * @param HookContext $context The hook context + * @param Exception $error The error that occurred + * @return void + */ + public function runErrorHooks(array $hooks, HookContext $context, Exception $error): void + { + foreach ($hooks as $hook) { + $errorCallback = $hook->getError(); + if ($errorCallback !== null) { + try { + call_user_func($errorCallback, $context, $error); + } catch (Exception $e) { + // Log the error but don't throw it + error_log("Error hook failed: " . $e->getMessage()); + } + } + } + } +} diff --git a/lib/Model/HookContext.php b/lib/Model/HookContext.php new file mode 100644 index 0000000..fddd2b1 --- /dev/null +++ b/lib/Model/HookContext.php @@ -0,0 +1,105 @@ +user = $user; + $this->key = $key; + $this->defaultValue = $defaultValue; + $this->variableDetails = $variableDetails; + } + + /** + * Get the user data + * + * @return DevCycleUser + */ + public function getUser(): DevCycleUser + { + return $this->user; + } + + /** + * Get the variable key + * + * @return string + */ + public function getKey(): string + { + return $this->key; + } + + /** + * Get the default value + * + * @return mixed + */ + public function getDefaultValue() + { + return $this->defaultValue; + } + + /** + * Get the variable details + * + * @return Variable|null + */ + public function getVariableDetails(): ?Variable + { + return $this->variableDetails; + } + + /** + * Set the variable details + * + * @param Variable|null $variableDetails + * @return void + */ + public function setVariableDetails(?Variable $variableDetails): void + { + $this->variableDetails = $variableDetails; + } +} diff --git a/test/Api/HookTest.php b/test/Api/HookTest.php new file mode 100644 index 0000000..c4f2b1d --- /dev/null +++ b/test/Api/HookTest.php @@ -0,0 +1,222 @@ +user = new DevCycleUser(array( + "user_id" => "test-hook-user" + )); + } + + /** + * Test that hooks work correctly with before, after, onFinally, and error callbacks + */ + public function testHooksWorkCorrectly() + { + $beforeCalled = false; + $afterCalled = false; + $onFinallyCalled = false; + $errorCalled = false; + + $hook = new EvalHook( + before: function (HookContext $context) use (&$beforeCalled) { + $beforeCalled = true; + $this->assertEquals('test-key', $context->getKey()); + $this->assertEquals('test-default', $context->getDefaultValue()); + $this->assertSame($this->user, $context->getUser()); + $this->assertNull($context->getVariableDetails()); + }, + after: function (HookContext $context) use (&$afterCalled) { + $afterCalled = true; + $this->assertEquals('test-key', $context->getKey()); + $this->assertEquals('test-default', $context->getDefaultValue()); + $this->assertSame($this->user, $context->getUser()); + $this->assertNotNull($context->getVariableDetails()); + }, + onFinally: function (HookContext $context) use (&$onFinallyCalled) { + $onFinallyCalled = true; + $this->assertEquals('test-key', $context->getKey()); + $this->assertEquals('test-default', $context->getDefaultValue()); + $this->assertSame($this->user, $context->getUser()); + }, + error: function (HookContext $context, \Exception $error) use (&$errorCalled) { + $errorCalled = true; + $this->assertEquals('test-key', $context->getKey()); + $this->assertEquals('test-default', $context->getDefaultValue()); + $this->assertSame($this->user, $context->getUser()); + } + ); + + $options = new DevCycleOptions(false, null, null, [], [$hook]); + $this->client = new DevCycleClient( + sdkKey: getenv("DEVCYCLE_SERVER_SDK_KEY") ?: "dvc_server_token_hash", + dvcOptions: $options + ); + + // Test with a non-existent variable to trigger default behavior + $result = $this->client->variable($this->user, 'test-key', 'test-default'); + + $this->assertTrue($beforeCalled, 'Before hook should be called'); + $this->assertTrue($afterCalled, 'After hook should be called'); + $this->assertTrue($onFinallyCalled, 'OnFinally hook should be called'); + // Error hook may be called due to API errors, which is expected behavior + $this->assertTrue($result->isDefaulted()); + $this->assertEquals('test-default', $result->getValue()); + } + + /** + * Test that before hook errors are caught and logged + */ + public function testBeforeHookErrorIsCaught() + { + $beforeCalled = false; + $afterCalled = false; + $onFinallyCalled = false; + $errorCalled = false; + + $hook = new EvalHook( + before: function (HookContext $context) use (&$beforeCalled) { + $beforeCalled = true; + throw new \Exception('Before hook error'); + }, + after: function (HookContext $context) use (&$afterCalled) { + $afterCalled = true; + }, + onFinally: function (HookContext $context) use (&$onFinallyCalled) { + $onFinallyCalled = true; + }, + error: function (HookContext $context, \Exception $error) use (&$errorCalled) { + $errorCalled = true; + $this->assertInstanceOf(BeforeHookError::class, $error); + } + ); + + $options = new DevCycleOptions(false, null, null, [], [$hook]); + $this->client = new DevCycleClient( + sdkKey: getenv("DEVCYCLE_SERVER_SDK_KEY") ?: "dvc_server_token_hash", + dvcOptions: $options + ); + + $result = $this->client->variable($this->user, 'test-key', 'test-default'); + + $this->assertTrue($beforeCalled, 'Before hook should be called'); + // After hook may still be called due to API errors, which is expected + $this->assertTrue($onFinallyCalled, 'OnFinally hook should be called'); + $this->assertTrue($errorCalled, 'Error hook should be called'); + $this->assertTrue($result->isDefaulted()); + $this->assertEquals('test-default', $result->getValue()); + } + + /** + * Test that after hook errors are caught and logged + */ + public function testAfterHookErrorIsCaught() + { + $beforeCalled = false; + $afterCalled = false; + $onFinallyCalled = false; + $errorCalled = false; + + $hook = new EvalHook( + before: function (HookContext $context) use (&$beforeCalled) { + $beforeCalled = true; + }, + after: function (HookContext $context) use (&$afterCalled) { + $afterCalled = true; + throw new \Exception('After hook error'); + }, + onFinally: function (HookContext $context) use (&$onFinallyCalled) { + $onFinallyCalled = true; + }, + error: function (HookContext $context, \Exception $error) use (&$errorCalled) { + $errorCalled = true; + $this->assertInstanceOf(AfterHookError::class, $error); + } + ); + + $options = new DevCycleOptions(false, null, null, [], [$hook]); + $this->client = new DevCycleClient( + sdkKey: getenv("DEVCYCLE_SERVER_SDK_KEY") ?: "dvc_server_token_hash", + dvcOptions: $options + ); + + $result = $this->client->variable($this->user, 'test-key', 'test-default'); + + $this->assertTrue($beforeCalled, 'Before hook should be called'); + $this->assertTrue($afterCalled, 'After hook should be called'); + $this->assertTrue($onFinallyCalled, 'OnFinally hook should be called'); + $this->assertTrue($errorCalled, 'Error hook should be called'); + $this->assertTrue($result->isDefaulted()); + $this->assertEquals('test-default', $result->getValue()); + } + + /** + * Test that functionality remains the same without hooks + */ + public function testFunctionalityWithoutHooks() + { + $options = new DevCycleOptions(false); + $this->client = new DevCycleClient( + sdkKey: getenv("DEVCYCLE_SERVER_SDK_KEY") ?: "dvc_server_token_hash", + dvcOptions: $options + ); + + $result = $this->client->variable($this->user, 'test-key', 'test-default'); + + $this->assertTrue($result->isDefaulted()); + $this->assertEquals('test-default', $result->getValue()); + } + + /** + * Test that onFinally hooks are always called even when errors occur + */ + public function testOnFinallyAlwaysCalled() + { + $onFinallyCalled = false; + + $hook = new EvalHook( + onFinally: function (HookContext $context) use (&$onFinallyCalled) { + $onFinallyCalled = true; + } + ); + + $options = new DevCycleOptions(false, null, null, [], [$hook]); + $this->client = new DevCycleClient( + sdkKey: getenv("DEVCYCLE_SERVER_SDK_KEY") ?: "dvc_server_token_hash", + dvcOptions: $options + ); + + $result = $this->client->variable($this->user, 'test-key', 'test-default'); + + $this->assertTrue($onFinallyCalled, 'OnFinally hook should always be called'); + $this->assertTrue($result->isDefaulted()); + $this->assertEquals('test-default', $result->getValue()); + } +} \ No newline at end of file