diff --git a/lib/Api/DevCycleClient.php b/lib/Api/DevCycleClient.php index 26beb76..01d5cda 100644 --- a/lib/Api/DevCycleClient.php +++ b/lib/Api/DevCycleClient.php @@ -13,6 +13,9 @@ use DevCycle\Model\InlineResponse201; use DevCycle\Model\Variable; use DevCycle\Model\EvalHookRunner; +use DevCycle\Model\EvalObject; +use DevCycle\Model\EvalReasons; +use DevCycle\Model\DefaultReasonDetails; use DevCycle\Model\HookContext; use DevCycle\Model\BeforeHookError; use DevCycle\Model\AfterHookError; @@ -317,11 +320,15 @@ public function variable(DevCycleUser $user, string $key, mixed $default): Varia $result = $this->reformatVariable($key, $response, $default); $context->setVariableDetails($result); } catch (GuzzleException|ApiException $e) { + $eval = (object) [ + 'reason' => EvalReasons::DEFAULT, + 'details' => DefaultReasonDetails::ERROR + ]; $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)); + $result = new Variable(array("key" => $key, "value" => $default, "type" => gettype($default), "isDefaulted" => true, "eval" => $eval)); $context->setVariableDetails($result); } @@ -370,13 +377,18 @@ private function reformatVariable(string $key, Variable $response, mixed $defaul } if (!$doTypesMatch) { - return new Variable(array("key" => $key, "value" => $default, "type" => $defaultType, "isDefaulted" => true)); + $eval = (object) [ + 'reason' => EvalReasons::DEFAULT, + 'details' => DefaultReasonDetails::TYPE_MISMATCH + ]; + return new Variable(array("key" => $key, "value" => $default, "type" => $defaultType, "isDefaulted" => true, "eval" => $eval)); } else { + $eval = $response->getEval(); if ($responseType === 'array') { $jsonValue = json_decode(json_encode($unwrappedValue), true); $unwrappedValue = $jsonValue; } - return new Variable(array("key" => $key, "value" => $unwrappedValue, "type" => $responseType, "isDefaulted" => false)); + return new Variable(array("key" => $key, "value" => $unwrappedValue, "type" => $responseType, "isDefaulted" => false, "eval" => $eval)); } } @@ -408,6 +420,11 @@ private function variableWithHttpInfo(DevCycleUser $user_data, string $key): arr try { list($response, $statusCode) = $this->makeRequest($request); + $eval = (object) [ + 'reason' => EvalReasons::DEFAULT, + 'details' => DefaultReasonDetails::MISSING_CONFIG + ]; + switch ($statusCode) { case 200: $content = (string)$response->getBody(); diff --git a/lib/Model/EvalReasons.php b/lib/Model/EvalReasons.php new file mode 100644 index 0000000..9b1e297 --- /dev/null +++ b/lib/Model/EvalReasons.php @@ -0,0 +1,45 @@ + 'string', 'key' => 'string', 'type' => 'string', - 'value' => 'object' + 'value' => 'object', + 'isDefaulted' => 'bool', + 'eval' => 'object' ]; /** @@ -55,7 +57,8 @@ class Variable implements ModelInterface, ArrayAccess, \JsonSerializable '_id' => null, 'key' => null, 'type' => null, - 'value' => null + 'value' => null, + 'eval' => null ]; /** @@ -89,7 +92,7 @@ public static function openAPIFormats(): array 'key' => 'key', 'type' => 'type', 'value' => 'value', - 'isDefaulted' => 'isDefaulted' + 'eval' => 'eval' ]; /** @@ -102,7 +105,7 @@ public static function openAPIFormats(): array 'key' => 'setKey', 'type' => 'setType', 'value' => 'setValue', - 'isDefaulted' => 'setIsDefaulted' + 'eval' => 'setEval' ]; /** @@ -115,7 +118,7 @@ public static function openAPIFormats(): array 'key' => 'getKey', 'type' => 'getType', 'value' => 'getValue', - 'isDefaulted' => 'getIsDefaulted' + 'eval' => 'getEval' ]; /** @@ -199,6 +202,8 @@ public function __construct(?array $data = null) $this->container['type'] = $data['type'] ?? null; $this->container['value'] = $data['value'] ?? null; $this->container['isDefaulted'] = $data['isDefaulted'] ?? false; + + $this->container['eval'] = $data['eval'] ?? null; } /** @@ -429,6 +434,36 @@ public function offsetUnset($offset):void unset($this->container[$offset]); } + /** + * Gets eval + * + * @return object|null + */ + public function getEval(): ?object + { + return $this->container['eval']; + } + + /** + * Sets eval + * + * @param object|array|null $eval Eval context + * + * @return self + */ + public function setEval($evalObj): static + { + // Convert array to object if needed + if (is_array($evalObj)) { + $this->container['eval'] = (object) $evalObj; + } else { + $this->container['eval'] = $evalObj; + } + + return $this; + } + + /** * Serializes the object to a value that can be serialized natively by json_encode(). * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php @@ -446,7 +481,7 @@ public function jsonSerialize():mixed * * @return string */ - public function __toString() + public function __toString(): string { return json_encode( ObjectSerializer::sanitizeForSerialization($this), @@ -459,7 +494,7 @@ public function __toString() * * @return string */ - public function toHeaderValue() + public function toHeaderValue(): string { return json_encode(ObjectSerializer::sanitizeForSerialization($this)); } @@ -468,11 +503,16 @@ public function asResolutionDetails(): ResolutionDetails { $resolution = new ResolutionDetails(); $resolution->setValue($this->getValue()); - $resolution->setReason(Reason::TARGETING_MATCH); - if ($this->isDefaulted()) { - $resolution->setError(new ResolutionError(ErrorCode::FLAG_NOT_FOUND(), "Defaulted")); - $resolution->setReason(Reason::DEFAULT); + if ($this->getEval() != null) { + $resolution->setReason($this->getEval()->reason); + } else { + $resolution->setReason(Reason::TARGETING_MATCH); + if ($this->isDefaulted()) { + $resolution->setError(new ResolutionError(ErrorCode::FLAG_NOT_FOUND(), "Defaulted")); + $resolution->setReason(Reason::DEFAULT); + } } + return $resolution; } } diff --git a/test/Api/DevCycleClientTest.php b/test/Api/DevCycleClientTest.php index 86f3133..40e58f5 100644 --- a/test/Api/DevCycleClientTest.php +++ b/test/Api/DevCycleClientTest.php @@ -23,6 +23,10 @@ use DevCycle\Model\DevCycleUser; use DevCycle\Model\DevCycleEvent; use DevCycle\Model\ErrorResponse; +use DevCycle\Model\EvalObject; +use DevCycle\Model\EvalReasons; +use DevCycle\Model\DefaultReasonDetails; +use DevCycle\Model\Variable; use Exception; use OpenFeature\implementation\flags\EvaluationContext; use OpenFeature\interfaces\flags\Client; @@ -30,6 +34,11 @@ use OpenFeature\OpenFeatureAPI; use OpenFeature\OpenFeatureClient; use PHPUnit\Framework\TestCase; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\Handler\MockHandler; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Psr7\Response; +use GuzzleHttp\Middleware; /** * DevCycleClientTest Class Doc Comment @@ -118,12 +127,16 @@ public function testGetVariableByKey() // add a value to the invocation context $boolValue = self::$openFeatureClient->getBooleanValue('test', false, self::$context); self::assertTrue($boolValue); + $resultValue = self::$client->variableValue(self::$user, 'test', false); self::assertTrue((bool)$resultValue); $result = self::$client->variable(self::$user, 'php-sdk-default-invalid', true); self::assertTrue($result->isDefaulted()); self::assertTrue((bool)$result->getValue()); + $eval = $result->getEval(); + self::assertEquals(EvalReasons::DEFAULT, $eval->reason); + self::assertEquals(DefaultReasonDetails::ERROR, $eval->details); } /** @@ -149,6 +162,15 @@ public function testVariable_invalidSDKKey_isDefaultedTrue() self::assertTrue($openFeatureValue); } + public function testVariableTypeMismatch() + { + $result = self::$client->variable(self::$user, 'test', 5); + self::assertTrue($result->isDefaulted()); + self::assertEquals(5, $result->getValue()); + self::assertEquals(EvalReasons::DEFAULT, $result->getEval()->reason); + self::assertEquals(DefaultReasonDetails::TYPE_MISMATCH, $result->getEval()->details); + } + public function testVariableDefaultedDoesNotThrow() { $result = self::$client->variable(self::$user, 'variable-does-not-exist', true); @@ -156,6 +178,118 @@ public function testVariableDefaultedDoesNotThrow() self::assertTrue((bool)$result->getValue()); } + /** + * Test case for mocking bucketing API response with evals + */ + public function testVariableWithMockedEvalResponse() + { + // Create a mock response with eval data + $mockResponse = [ + '_id' => 'mock-variable-id', + 'key' => 'mock-variable-key', + 'type' => 'Boolean', + 'value' => true, + 'isDefaulted' => false, + 'eval' => [ + 'reason' => 'TARGETING_MATCH', + 'details' => 'Random Distribution | All Users', + 'target_id' => 'mock-target-id' + ] + ]; + + // Create mock handler + $mock = new MockHandler([ + new Response(200, [], json_encode($mockResponse)) + ]); + + $handlerStack = HandlerStack::create($mock); + $mockClient = new GuzzleClient(['handler' => $handlerStack]); + + // Create client with mocked HTTP client + $options = new DevCycleOptions(true); + $mockedClient = new DevCycleClient( + sdkKey: 'server-sdk-key', + dvcOptions: $options, + client: $mockClient + ); + + $user = new DevCycleUser(array("user_id" => "mock-user")); + $result = $mockedClient->variable($user, 'mock-variable-key', false); + + // Verify the response contains the eval object + self::assertInstanceOf(Variable::class, $result); + self::assertEquals('mock-variable-key', $result->getKey()); + self::assertTrue($result->getValue()); + self::assertFalse($result->isDefaulted()); + + // Verify the eval object + $eval = $result->getEval(); + self::assertEquals('TARGETING_MATCH', $eval->reason); + self::assertEquals('Random Distribution | All Users', $eval->details); + self::assertEquals('mock-target-id', $eval->target_id); + } + + /** + * Test case for mocking bucketing API response from SDK Proxy + */ + public function testVariableWithMockedEvalResponseFromSDKProxy() + { + // Create a mock response with eval data + $mockResponse = [ + '_id' => 'mock-variable-id', + 'key' => 'mock-variable-key', + 'type' => 'Boolean', + 'value' => true, + 'isDefaulted' => false, + 'eval' => [ + 'reason' => 'SPLIT' + ] + ]; + + // Create mock handler with multiple responses for multiple API calls + $mock = new MockHandler([ + new Response(200, [], json_encode($mockResponse)), + new Response(200, [], json_encode($mockResponse)) + ]); + + $handlerStack = HandlerStack::create($mock); + $mockClient = new GuzzleClient(['handler' => $handlerStack]); + + // Create client with mocked HTTP client + $options = new DevCycleOptions(true); + $mockedClient = new DevCycleClient( + sdkKey: 'server-sdk-key', + dvcOptions: $options, + client: $mockClient + ); + + $user = new DevCycleUser(array("user_id" => "mock-user")); + $result = $mockedClient->variable($user, 'mock-variable-key', false); + + // Verify the response contains the eval object + self::assertInstanceOf(Variable::class, $result); + self::assertEquals('mock-variable-key', $result->getKey()); + self::assertTrue($result->getValue()); + self::assertFalse($result->isDefaulted()); + + // Verify the eval object + $eval = $result->getEval(); + self::assertEquals('SPLIT', $eval->reason); + self::assertNull($eval->details); + self::assertNull($eval->target_id); + + $mockedProvider = $mockedClient->getOpenFeatureProvider(); + self::$api->setProvider($mockedProvider); + $mockedOpenFeatureClient = self::$api->getClient(); + + $boolDetails = $mockedOpenFeatureClient->getBooleanDetails('mock-variable-key', false, self::$context); + self::assertEquals(true, $boolDetails->getValue()); + self::assertEquals('SPLIT', $boolDetails->getReason()); + + // set it back to the original provider + self::$api->setProvider(self::$client->getOpenFeatureProvider()); + } + /** * Test case for getVariables * diff --git a/test/Api/OpenFeatureTest.php b/test/Api/OpenFeatureTest.php index 205d793..f0976aa 100644 --- a/test/Api/OpenFeatureTest.php +++ b/test/Api/OpenFeatureTest.php @@ -179,4 +179,6 @@ public function testEvaluationContext() self::assertEquals(1, $user->getCustomData()['nonSetValueBubbledCustomData3'], 'Non Set Value Bubbled Custom Data 3 not properly passed through/set'); self::assertEquals(null, $user->getCustomData()['nonSetValueBubbledCustomData4'], 'Non Set Value Bubbled Custom Data 4 not properly passed through/set'); } + + }