From 0168ec3a6350d99350a3a348205bfaf76210562e Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 13:12:57 -0400 Subject: [PATCH 1/8] feat: added eval reason support --- .github/workflows/run-test-harness.yml | 1 + lib/Model/Variable.php | 37 +++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index ab595da..e112a85 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,3 +14,4 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} + sdk-capabilities: '{ "PHP": ["cloudProxy", "evalReason", "cloudEvalReason"]}' diff --git a/lib/Model/Variable.php b/lib/Model/Variable.php index e4d14a3..4e23f9b 100644 --- a/lib/Model/Variable.php +++ b/lib/Model/Variable.php @@ -55,7 +55,8 @@ class Variable implements ModelInterface, ArrayAccess, \JsonSerializable '_id' => null, 'key' => null, 'type' => null, - 'value' => null + 'value' => null, + 'eval' => null ]; /** @@ -89,7 +90,8 @@ public static function openAPIFormats(): array 'key' => 'key', 'type' => 'type', 'value' => 'value', - 'isDefaulted' => 'isDefaulted' + 'isDefaulted' => 'isDefaulted', + 'eval' => 'eval' ]; /** @@ -102,7 +104,8 @@ public static function openAPIFormats(): array 'key' => 'setKey', 'type' => 'setType', 'value' => 'setValue', - 'isDefaulted' => 'setIsDefaulted' + 'isDefaulted' => 'setIsDefaulted', + 'eval' => 'setEval' ]; /** @@ -115,7 +118,8 @@ public static function openAPIFormats(): array 'key' => 'getKey', 'type' => 'getType', 'value' => 'getValue', - 'isDefaulted' => 'getIsDefaulted' + 'isDefaulted' => 'getIsDefaulted', + 'eval' => 'getEval' ]; /** @@ -429,6 +433,31 @@ public function offsetUnset($offset):void unset($this->container[$offset]); } + /** + * Gets eval + * + * @return mixed + */ + public function getEval(): mixed + { + return $this->container['eval']; + } + + /** + * Sets eval + * + * @param mixed $eval Eval context + * + * @return self + */ + public function setEval(mixed $eval): static + { + $this->container['eval'] = $eval; + + 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 From 4e6d25df8e19f7a4a8ebc8f12e1108b7d6d6e2db Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 14:56:35 -0400 Subject: [PATCH 2/8] chore: added eval reason defaults --- .github/workflows/run-test-harness.yml | 2 +- lib/Api/DevCycleClient.php | 13 ++++++-- lib/Model/EvalObject.php | 41 +++++++++++++++++++++++ lib/Model/EvalReasons.php | 45 ++++++++++++++++++++++++++ lib/Model/Variable.php | 13 ++++---- test/Api/DevCycleClientTest.php | 15 +++++++++ 6 files changed, 119 insertions(+), 10 deletions(-) create mode 100644 lib/Model/EvalObject.php create mode 100644 lib/Model/EvalReasons.php diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index e112a85..003fc51 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,4 +14,4 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} - sdk-capabilities: '{ "PHP": ["cloudProxy", "evalReason", "cloudEvalReason"]}' + sdk-capabilities: '{ "PHP": ["cloudProxy", "cloudEvalReason"]}' diff --git a/lib/Api/DevCycleClient.php b/lib/Api/DevCycleClient.php index 26beb76..c944a91 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,12 @@ public function variable(DevCycleUser $user, string $key, mixed $default): Varia $result = $this->reformatVariable($key, $response, $default); $context->setVariableDetails($result); } catch (GuzzleException|ApiException $e) { + $eval = new EvalObject(EvalReasons::DEFAULT, 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 +374,14 @@ private function reformatVariable(string $key, Variable $response, mixed $defaul } if (!$doTypesMatch) { - return new Variable(array("key" => $key, "value" => $default, "type" => $defaultType, "isDefaulted" => true)); + $eval = new EvalObject(EvalReasons::DEFAULT, DefaultReasonDetails::TYPE_MISMATCH); + return new Variable(array("key" => $key, "value" => $default, "type" => $defaultType, "isDefaulted" => true, "eval" => $eval)); } else { 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 +413,8 @@ private function variableWithHttpInfo(DevCycleUser $user_data, string $key): arr try { list($response, $statusCode) = $this->makeRequest($request); + $eval = new EvalObject(EvalReasons::DEFAULT, DefaultReasonDetails::MISSING_CONFIG); + switch ($statusCode) { case 200: $content = (string)$response->getBody(); diff --git a/lib/Model/EvalObject.php b/lib/Model/EvalObject.php new file mode 100644 index 0000000..6dfc136 --- /dev/null +++ b/lib/Model/EvalObject.php @@ -0,0 +1,41 @@ +reason = $reason; + $this->details = $details; + $this->target_id = $target_id; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getDetails(): string + { + return $this->details; + } + + public function getTargetId(): ?string + { + return $this->target_id; + } + + public function jsonSerialize(): array + { + return [ + 'reason' => $this->reason, + 'details' => $this->details, + 'target_id' => $this->target_id + ]; + } +} \ No newline at end of file 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 @@ +container['type'] = $data['type'] ?? null; $this->container['value'] = $data['value'] ?? null; $this->container['isDefaulted'] = $data['isDefaulted'] ?? false; + $this->container['eval'] = $data['eval'] ?? null; } /** @@ -436,9 +437,9 @@ public function offsetUnset($offset):void /** * Gets eval * - * @return mixed - */ - public function getEval(): mixed + * @return \Eval|null + */ + public function getEval(): ?EvalObject { return $this->container['eval']; } @@ -446,13 +447,13 @@ public function getEval(): mixed /** * Sets eval * - * @param mixed $eval Eval context + * @param Eval $eval Eval context * * @return self */ - public function setEval(mixed $eval): static + public function setEval(EvalObject $evalObj): static { - $this->container['eval'] = $eval; + $this->container['eval'] = $evalObj; return $this; } diff --git a/test/Api/DevCycleClientTest.php b/test/Api/DevCycleClientTest.php index 86f3133..79e70d8 100644 --- a/test/Api/DevCycleClientTest.php +++ b/test/Api/DevCycleClientTest.php @@ -23,6 +23,9 @@ 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 Exception; use OpenFeature\implementation\flags\EvaluationContext; use OpenFeature\interfaces\flags\Client; @@ -124,6 +127,9 @@ public function testGetVariableByKey() $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->getReason()); + self::assertEquals(DefaultReasonDetails::ERROR, $eval->getDetails()); } /** @@ -149,6 +155,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()->getReason()); + self::assertEquals(DefaultReasonDetails::TYPE_MISMATCH, $result->getEval()->getDetails()); + } + public function testVariableDefaultedDoesNotThrow() { $result = self::$client->variable(self::$user, 'variable-does-not-exist', true); From d12350beb2f1d6bb468aaa9221bec89dddded654 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 15:38:47 -0400 Subject: [PATCH 3/8] chore: try to run cloud tests for php on test harness --- .github/workflows/run-test-harness.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index 003fc51..1532767 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,4 +14,4 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} - sdk-capabilities: '{ "PHP": ["cloudProxy", "cloudEvalReason"]}' + sdk-capabilities: '{ "PHP": ["cloudProxy", "cloud", "cloudEvalReason"]}' From 199aede9605602bbb754d1560a1027503e483da5 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 15:44:51 -0400 Subject: [PATCH 4/8] chore: remove cloud functionality on test harness --- .github/workflows/run-test-harness.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index 1532767..003fc51 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,4 +14,4 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} - sdk-capabilities: '{ "PHP": ["cloudProxy", "cloud", "cloudEvalReason"]}' + sdk-capabilities: '{ "PHP": ["cloudProxy", "cloudEvalReason"]}' From 6039fd5a93580c79e6de9fc01d2c34ad7f040f28 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 16:41:42 -0400 Subject: [PATCH 5/8] chore: added tests for deserializing eval object --- .github/workflows/run-test-harness.yml | 2 +- lib/Api/DevCycleClient.php | 13 +- lib/Model/EvalObject.php | 349 +++++++++++++++++++++++-- lib/Model/Variable.php | 26 +- test/Api/DevCycleClientTest.php | 57 ++++ 5 files changed, 420 insertions(+), 27 deletions(-) diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index 003fc51..8de790c 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,4 +14,4 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} - sdk-capabilities: '{ "PHP": ["cloudProxy", "cloudEvalReason"]}' + sdk-capabilities: '{ "PHP": ["cloudProxy"]}' diff --git a/lib/Api/DevCycleClient.php b/lib/Api/DevCycleClient.php index c944a91..fe418e2 100644 --- a/lib/Api/DevCycleClient.php +++ b/lib/Api/DevCycleClient.php @@ -320,7 +320,9 @@ public function variable(DevCycleUser $user, string $key, mixed $default): Varia $result = $this->reformatVariable($key, $response, $default); $context->setVariableDetails($result); } catch (GuzzleException|ApiException $e) { - $eval = new EvalObject(EvalReasons::DEFAULT, DefaultReasonDetails::ERROR); + $eval = new EvalObject(); + $eval->setReason(EvalReasons::DEFAULT); + $eval->setDetails(DefaultReasonDetails::ERROR); $evaluationError = $e; if ($e->getCode() != 404) { error_log("Failed to get variable value for key $key, " . $e->getMessage()); @@ -374,9 +376,12 @@ private function reformatVariable(string $key, Variable $response, mixed $defaul } if (!$doTypesMatch) { - $eval = new EvalObject(EvalReasons::DEFAULT, DefaultReasonDetails::TYPE_MISMATCH); + $eval = new EvalObject(); + $eval->setReason(EvalReasons::DEFAULT); + $eval->setDetails(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; @@ -413,7 +418,9 @@ private function variableWithHttpInfo(DevCycleUser $user_data, string $key): arr try { list($response, $statusCode) = $this->makeRequest($request); - $eval = new EvalObject(EvalReasons::DEFAULT, DefaultReasonDetails::MISSING_CONFIG); + $eval = new EvalObject(); + $eval->setReason(EvalReasons::DEFAULT); + $eval->setDetails(DefaultReasonDetails::MISSING_CONFIG); switch ($statusCode) { case 200: diff --git a/lib/Model/EvalObject.php b/lib/Model/EvalObject.php index 6dfc136..faddf67 100644 --- a/lib/Model/EvalObject.php +++ b/lib/Model/EvalObject.php @@ -2,40 +2,355 @@ namespace DevCycle\Model; -class EvalObject implements \JsonSerializable +use \ArrayAccess; +use \DevCycle\ObjectSerializer; + +/** + * EvalObject Class Doc Comment + * + * @category Class + * @package DevCycle + * @author OpenAPI Generator team + * @link https://openapi-generator.tech + * @implements \ArrayAccess + * @template TKey int|null + * @template TValue mixed|null + */ +class EvalObject implements ModelInterface, ArrayAccess, \JsonSerializable { - private string $reason; - private string $details; - private ?string $target_id; + /** + * The original name of the model. + * + * @var string + */ + protected static string $openAPIModelName = 'EvalObject'; + + /** + * Array of property to type mappings. Used for (de)serialization + * + * @var string[] + */ + protected static array $openAPITypes = [ + 'reason' => 'string', + 'details' => 'string', + 'target_id' => 'string' + ]; + + /** + * Array of property to format mappings. Used for (de)serialization + * + * @var string[] + * @phpstan-var array + * @psalm-var array + */ + protected static array $openAPIFormats = [ + 'reason' => null, + 'details' => null, + 'target_id' => null + ]; + + /** + * Array of property to type mappings. Used for (de)serialization + * + * @return array + */ + public static function openAPITypes(): array + { + return self::$openAPITypes; + } + + /** + * Array of property to format mappings. Used for (de)serialization + * + * @return array + */ + public static function openAPIFormats(): array + { + return self::$openAPIFormats; + } + + /** + * Array of attributes where the key is the local name, + * and the value is the original name + * + * @var string[] + */ + protected static array $attributeMap = [ + 'reason' => 'reason', + 'details' => 'details', + 'target_id' => 'target_id' + ]; + + /** + * Array of attributes to setter functions (for deserialization of responses) + * + * @var string[] + */ + protected static array $setters = [ + 'reason' => 'setReason', + 'details' => 'setDetails', + 'target_id' => 'setTargetId' + ]; + + /** + * Array of attributes to getter functions (for serialization of requests) + * + * @var string[] + */ + protected static array $getters = [ + 'reason' => 'getReason', + 'details' => 'getDetails', + 'target_id' => 'getTargetId' + ]; + + /** + * Array of attributes where the key is the local name, + * and the value is the original name + * + * @return array + */ + public static function attributeMap(): array + { + return self::$attributeMap; + } + + /** + * Array of attributes to setter functions (for deserialization of responses) + * + * @return array + */ + public static function setters(): array + { + return self::$setters; + } + + /** + * Array of attributes to getter functions (for serialization of requests) + * + * @return array + */ + public static function getters(): array + { + return self::$getters; + } + + /** + * The original name of the model. + * + * @return string + */ + public function getModelName(): string + { + return self::$openAPIModelName; + } - public function __construct(string $reason, string $details, ?string $target_id = null) + /** + * Associative array for storing property values + * + * @var array[] + */ + protected mixed $container = []; + + /** + * Constructor + * + * @param mixed[]|null $data Associated array of property values + * initializing the model + */ + public function __construct(?array $data = null) + { + $this->container['reason'] = $data['reason'] ?? null; + $this->container['details'] = $data['details'] ?? null; + $this->container['target_id'] = $data['target_id'] ?? null; + } + + /** + * Show all the invalid properties with reasons. + * + * @return array invalid properties with reasons + */ + public function listInvalidProperties(): array { - $this->reason = $reason; - $this->details = $details; - $this->target_id = $target_id; + $invalidProperties = []; + + if ($this->container['reason'] === null) { + $invalidProperties[] = "'reason' can't be null"; + } + if ($this->container['details'] === null) { + $invalidProperties[] = "'details' can't be null"; + } + + return $invalidProperties; } + /** + * Validate all the properties in the model + * return true if all passed + * + * @return bool True if all properties are valid + */ + public function valid(): bool + { + return count($this->listInvalidProperties()) === 0; + } + + /** + * Gets reason + * + * @return string + */ public function getReason(): string { - return $this->reason; + return $this->container['reason']; + } + + /** + * Sets reason + * + * @param string $reason The reason for the evaluation + * + * @return self + */ + public function setReason(string $reason): self + { + $this->container['reason'] = $reason; + + return $this; } + /** + * Gets details + * + * @return string + */ public function getDetails(): string { - return $this->details; + return $this->container['details']; + } + + /** + * Sets details + * + * @param string $details Additional details about the evaluation + * + * @return self + */ + public function setDetails(string $details): self + { + $this->container['details'] = $details; + + return $this; } + /** + * Gets target_id + * + * @return string|null + */ public function getTargetId(): ?string { - return $this->target_id; + return $this->container['target_id']; + } + + /** + * Sets target_id + * + * @param string|null $target_id The target ID for the evaluation + * + * @return self + */ + public function setTargetId(?string $target_id): self + { + $this->container['target_id'] = $target_id; + + return $this; + } + + /** + * Returns true if offset exists. False otherwise. + * + * @param integer $offset Offset + * + * @return boolean + */ + public function offsetExists($offset): bool + { + return isset($this->container[$offset]); + } + + /** + * Gets offset. + * + * @param integer $offset Offset + * + * @return mixed|null + */ + public function offsetGet($offset): mixed + { + return $this->container[$offset] ?? null; + } + + /** + * Sets value based on offset. + * + * @param int|null $offset Offset + * @param mixed $value Value to be set + * + * @return void + */ + public function offsetSet($offset, mixed $value): void + { + if (is_null($offset)) { + $this->container[] = $value; + } else { + $this->container[$offset] = $value; + } + } + + /** + * Unsets offset. + * + * @param integer $offset Offset + * + * @return void + */ + public function offsetUnset($offset): void + { + unset($this->container[$offset]); + } + + /** + * Serializes the object to a value that can be serialized natively by json_encode(). + * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php + * + * @return mixed Returns data which can be serialized by json_encode(), which is a value + * of any type other than a resource. + */ + public function jsonSerialize(): mixed + { + return ObjectSerializer::sanitizeForSerialization($this); + } + + /** + * Gets the string presentation of the object + * + * @return string + */ + public function __toString(): string + { + return json_encode( + ObjectSerializer::sanitizeForSerialization($this), + JSON_PRETTY_PRINT + ); } - public function jsonSerialize(): array + /** + * Gets a header-safe presentation of the object + * + * @return string + */ + public function toHeaderValue(): string { - return [ - 'reason' => $this->reason, - 'details' => $this->details, - 'target_id' => $this->target_id - ]; + return json_encode(ObjectSerializer::sanitizeForSerialization($this)); } } \ No newline at end of file diff --git a/lib/Model/Variable.php b/lib/Model/Variable.php index b61aa32..be706ad 100644 --- a/lib/Model/Variable.php +++ b/lib/Model/Variable.php @@ -41,7 +41,9 @@ class Variable implements ModelInterface, ArrayAccess, \JsonSerializable '_id' => 'string', 'key' => 'string', 'type' => 'string', - 'value' => 'object' + 'value' => 'object', + 'isDefaulted' => 'bool', + 'eval' => '\DevCycle\Model\EvalObject' ]; /** @@ -203,7 +205,19 @@ 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; + + // Handle eval property - if it's already an EvalObject, use it directly + // otherwise, deserialize it properly + if (isset($data['eval'])) { + if ($data['eval'] instanceof EvalObject) { + $this->container['eval'] = $data['eval']; + } else { + // Deserialize the eval data into an EvalObject + $this->container['eval'] = ObjectSerializer::deserialize($data['eval'], '\DevCycle\Model\EvalObject'); + } + } else { + $this->container['eval'] = null; + } } /** @@ -447,11 +461,11 @@ public function getEval(): ?EvalObject /** * Sets eval * - * @param Eval $eval Eval context + * @param EvalObject|null $eval Eval context * * @return self */ - public function setEval(EvalObject $evalObj): static + public function setEval(?EvalObject $evalObj): static { $this->container['eval'] = $evalObj; @@ -476,7 +490,7 @@ public function jsonSerialize():mixed * * @return string */ - public function __toString() + public function __toString(): string { return json_encode( ObjectSerializer::sanitizeForSerialization($this), @@ -489,7 +503,7 @@ public function __toString() * * @return string */ - public function toHeaderValue() + public function toHeaderValue(): string { return json_encode(ObjectSerializer::sanitizeForSerialization($this)); } diff --git a/test/Api/DevCycleClientTest.php b/test/Api/DevCycleClientTest.php index 79e70d8..3467e7c 100644 --- a/test/Api/DevCycleClientTest.php +++ b/test/Api/DevCycleClientTest.php @@ -26,6 +26,7 @@ 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; @@ -33,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 @@ -171,6 +177,57 @@ 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->getReason()); + self::assertEquals('Random Distribution | All Users', $eval->getDetails()); + self::assertEquals('mock-target-id', $eval->getTargetId()); + } + /** * Test case for getVariables * From 862eb757c6ea3bb288322a48387eb17b06edefa5 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Thu, 31 Jul 2025 16:46:45 -0400 Subject: [PATCH 6/8] chore: use primitive instead of class --- .github/workflows/run-test-harness.yml | 1 - lib/Api/DevCycleClient.php | 21 +- lib/Model/EvalObject.php | 356 ------------------------- lib/Model/Variable.php | 30 +-- test/Api/DevCycleClientTest.php | 14 +- 5 files changed, 31 insertions(+), 391 deletions(-) delete mode 100644 lib/Model/EvalObject.php diff --git a/.github/workflows/run-test-harness.yml b/.github/workflows/run-test-harness.yml index 8de790c..ab595da 100644 --- a/.github/workflows/run-test-harness.yml +++ b/.github/workflows/run-test-harness.yml @@ -14,4 +14,3 @@ jobs: with: sdks-to-test: php sdk-github-sha: ${{github.event.pull_request.head.sha}} - sdk-capabilities: '{ "PHP": ["cloudProxy"]}' diff --git a/lib/Api/DevCycleClient.php b/lib/Api/DevCycleClient.php index fe418e2..01d5cda 100644 --- a/lib/Api/DevCycleClient.php +++ b/lib/Api/DevCycleClient.php @@ -320,9 +320,10 @@ public function variable(DevCycleUser $user, string $key, mixed $default): Varia $result = $this->reformatVariable($key, $response, $default); $context->setVariableDetails($result); } catch (GuzzleException|ApiException $e) { - $eval = new EvalObject(); - $eval->setReason(EvalReasons::DEFAULT); - $eval->setDetails(DefaultReasonDetails::ERROR); + $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()); @@ -376,9 +377,10 @@ private function reformatVariable(string $key, Variable $response, mixed $defaul } if (!$doTypesMatch) { - $eval = new EvalObject(); - $eval->setReason(EvalReasons::DEFAULT); - $eval->setDetails(DefaultReasonDetails::TYPE_MISMATCH); + $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(); @@ -418,9 +420,10 @@ private function variableWithHttpInfo(DevCycleUser $user_data, string $key): arr try { list($response, $statusCode) = $this->makeRequest($request); - $eval = new EvalObject(); - $eval->setReason(EvalReasons::DEFAULT); - $eval->setDetails(DefaultReasonDetails::MISSING_CONFIG); + $eval = (object) [ + 'reason' => EvalReasons::DEFAULT, + 'details' => DefaultReasonDetails::MISSING_CONFIG + ]; switch ($statusCode) { case 200: diff --git a/lib/Model/EvalObject.php b/lib/Model/EvalObject.php deleted file mode 100644 index faddf67..0000000 --- a/lib/Model/EvalObject.php +++ /dev/null @@ -1,356 +0,0 @@ - - * @template TKey int|null - * @template TValue mixed|null - */ -class EvalObject implements ModelInterface, ArrayAccess, \JsonSerializable -{ - /** - * The original name of the model. - * - * @var string - */ - protected static string $openAPIModelName = 'EvalObject'; - - /** - * Array of property to type mappings. Used for (de)serialization - * - * @var string[] - */ - protected static array $openAPITypes = [ - 'reason' => 'string', - 'details' => 'string', - 'target_id' => 'string' - ]; - - /** - * Array of property to format mappings. Used for (de)serialization - * - * @var string[] - * @phpstan-var array - * @psalm-var array - */ - protected static array $openAPIFormats = [ - 'reason' => null, - 'details' => null, - 'target_id' => null - ]; - - /** - * Array of property to type mappings. Used for (de)serialization - * - * @return array - */ - public static function openAPITypes(): array - { - return self::$openAPITypes; - } - - /** - * Array of property to format mappings. Used for (de)serialization - * - * @return array - */ - public static function openAPIFormats(): array - { - return self::$openAPIFormats; - } - - /** - * Array of attributes where the key is the local name, - * and the value is the original name - * - * @var string[] - */ - protected static array $attributeMap = [ - 'reason' => 'reason', - 'details' => 'details', - 'target_id' => 'target_id' - ]; - - /** - * Array of attributes to setter functions (for deserialization of responses) - * - * @var string[] - */ - protected static array $setters = [ - 'reason' => 'setReason', - 'details' => 'setDetails', - 'target_id' => 'setTargetId' - ]; - - /** - * Array of attributes to getter functions (for serialization of requests) - * - * @var string[] - */ - protected static array $getters = [ - 'reason' => 'getReason', - 'details' => 'getDetails', - 'target_id' => 'getTargetId' - ]; - - /** - * Array of attributes where the key is the local name, - * and the value is the original name - * - * @return array - */ - public static function attributeMap(): array - { - return self::$attributeMap; - } - - /** - * Array of attributes to setter functions (for deserialization of responses) - * - * @return array - */ - public static function setters(): array - { - return self::$setters; - } - - /** - * Array of attributes to getter functions (for serialization of requests) - * - * @return array - */ - public static function getters(): array - { - return self::$getters; - } - - /** - * The original name of the model. - * - * @return string - */ - public function getModelName(): string - { - return self::$openAPIModelName; - } - - /** - * Associative array for storing property values - * - * @var array[] - */ - protected mixed $container = []; - - /** - * Constructor - * - * @param mixed[]|null $data Associated array of property values - * initializing the model - */ - public function __construct(?array $data = null) - { - $this->container['reason'] = $data['reason'] ?? null; - $this->container['details'] = $data['details'] ?? null; - $this->container['target_id'] = $data['target_id'] ?? null; - } - - /** - * Show all the invalid properties with reasons. - * - * @return array invalid properties with reasons - */ - public function listInvalidProperties(): array - { - $invalidProperties = []; - - if ($this->container['reason'] === null) { - $invalidProperties[] = "'reason' can't be null"; - } - if ($this->container['details'] === null) { - $invalidProperties[] = "'details' can't be null"; - } - - return $invalidProperties; - } - - /** - * Validate all the properties in the model - * return true if all passed - * - * @return bool True if all properties are valid - */ - public function valid(): bool - { - return count($this->listInvalidProperties()) === 0; - } - - /** - * Gets reason - * - * @return string - */ - public function getReason(): string - { - return $this->container['reason']; - } - - /** - * Sets reason - * - * @param string $reason The reason for the evaluation - * - * @return self - */ - public function setReason(string $reason): self - { - $this->container['reason'] = $reason; - - return $this; - } - - /** - * Gets details - * - * @return string - */ - public function getDetails(): string - { - return $this->container['details']; - } - - /** - * Sets details - * - * @param string $details Additional details about the evaluation - * - * @return self - */ - public function setDetails(string $details): self - { - $this->container['details'] = $details; - - return $this; - } - - /** - * Gets target_id - * - * @return string|null - */ - public function getTargetId(): ?string - { - return $this->container['target_id']; - } - - /** - * Sets target_id - * - * @param string|null $target_id The target ID for the evaluation - * - * @return self - */ - public function setTargetId(?string $target_id): self - { - $this->container['target_id'] = $target_id; - - return $this; - } - - /** - * Returns true if offset exists. False otherwise. - * - * @param integer $offset Offset - * - * @return boolean - */ - public function offsetExists($offset): bool - { - return isset($this->container[$offset]); - } - - /** - * Gets offset. - * - * @param integer $offset Offset - * - * @return mixed|null - */ - public function offsetGet($offset): mixed - { - return $this->container[$offset] ?? null; - } - - /** - * Sets value based on offset. - * - * @param int|null $offset Offset - * @param mixed $value Value to be set - * - * @return void - */ - public function offsetSet($offset, mixed $value): void - { - if (is_null($offset)) { - $this->container[] = $value; - } else { - $this->container[$offset] = $value; - } - } - - /** - * Unsets offset. - * - * @param integer $offset Offset - * - * @return void - */ - public function offsetUnset($offset): void - { - unset($this->container[$offset]); - } - - /** - * Serializes the object to a value that can be serialized natively by json_encode(). - * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php - * - * @return mixed Returns data which can be serialized by json_encode(), which is a value - * of any type other than a resource. - */ - public function jsonSerialize(): mixed - { - return ObjectSerializer::sanitizeForSerialization($this); - } - - /** - * Gets the string presentation of the object - * - * @return string - */ - public function __toString(): string - { - return json_encode( - ObjectSerializer::sanitizeForSerialization($this), - JSON_PRETTY_PRINT - ); - } - - /** - * Gets a header-safe presentation of the object - * - * @return string - */ - public function toHeaderValue(): string - { - return json_encode(ObjectSerializer::sanitizeForSerialization($this)); - } -} \ No newline at end of file diff --git a/lib/Model/Variable.php b/lib/Model/Variable.php index be706ad..515159f 100644 --- a/lib/Model/Variable.php +++ b/lib/Model/Variable.php @@ -43,7 +43,7 @@ class Variable implements ModelInterface, ArrayAccess, \JsonSerializable 'type' => 'string', 'value' => 'object', 'isDefaulted' => 'bool', - 'eval' => '\DevCycle\Model\EvalObject' + 'eval' => 'object' ]; /** @@ -206,18 +206,7 @@ public function __construct(?array $data = null) $this->container['value'] = $data['value'] ?? null; $this->container['isDefaulted'] = $data['isDefaulted'] ?? false; - // Handle eval property - if it's already an EvalObject, use it directly - // otherwise, deserialize it properly - if (isset($data['eval'])) { - if ($data['eval'] instanceof EvalObject) { - $this->container['eval'] = $data['eval']; - } else { - // Deserialize the eval data into an EvalObject - $this->container['eval'] = ObjectSerializer::deserialize($data['eval'], '\DevCycle\Model\EvalObject'); - } - } else { - $this->container['eval'] = null; - } + $this->container['eval'] = $data['eval'] ?? null; } /** @@ -451,9 +440,9 @@ public function offsetUnset($offset):void /** * Gets eval * - * @return \Eval|null + * @return object|null */ - public function getEval(): ?EvalObject + public function getEval(): ?object { return $this->container['eval']; } @@ -461,13 +450,18 @@ public function getEval(): ?EvalObject /** * Sets eval * - * @param EvalObject|null $eval Eval context + * @param object|array|null $eval Eval context * * @return self */ - public function setEval(?EvalObject $evalObj): static + public function setEval($evalObj): static { - $this->container['eval'] = $evalObj; + // Convert array to object if needed + if (is_array($evalObj)) { + $this->container['eval'] = (object) $evalObj; + } else { + $this->container['eval'] = $evalObj; + } return $this; } diff --git a/test/Api/DevCycleClientTest.php b/test/Api/DevCycleClientTest.php index 3467e7c..8cf34f7 100644 --- a/test/Api/DevCycleClientTest.php +++ b/test/Api/DevCycleClientTest.php @@ -134,8 +134,8 @@ public function testGetVariableByKey() self::assertTrue($result->isDefaulted()); self::assertTrue((bool)$result->getValue()); $eval = $result->getEval(); - self::assertEquals(EvalReasons::DEFAULT, $eval->getReason()); - self::assertEquals(DefaultReasonDetails::ERROR, $eval->getDetails()); + self::assertEquals(EvalReasons::DEFAULT, $eval->reason); + self::assertEquals(DefaultReasonDetails::ERROR, $eval->details); } /** @@ -166,8 +166,8 @@ 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()->getReason()); - self::assertEquals(DefaultReasonDetails::TYPE_MISMATCH, $result->getEval()->getDetails()); + self::assertEquals(EvalReasons::DEFAULT, $result->getEval()->reason); + self::assertEquals(DefaultReasonDetails::TYPE_MISMATCH, $result->getEval()->details); } public function testVariableDefaultedDoesNotThrow() @@ -223,9 +223,9 @@ public function testVariableWithMockedEvalResponse() // Verify the eval object $eval = $result->getEval(); - self::assertEquals('TARGETING_MATCH', $eval->getReason()); - self::assertEquals('Random Distribution | All Users', $eval->getDetails()); - self::assertEquals('mock-target-id', $eval->getTargetId()); + self::assertEquals('TARGETING_MATCH', $eval->reason); + self::assertEquals('Random Distribution | All Users', $eval->details); + self::assertEquals('mock-target-id', $eval->target_id); } /** From ab849ab84ade706829c0e18332ffae7a77604bc6 Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Fri, 1 Aug 2025 11:27:52 -0400 Subject: [PATCH 7/8] chore: support for openfeature --- lib/Model/Variable.php | 13 ++++--- test/Api/DevCycleClientTest.php | 62 +++++++++++++++++++++++++++++++++ test/Api/OpenFeatureTest.php | 2 ++ 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/lib/Model/Variable.php b/lib/Model/Variable.php index 515159f..1153f8e 100644 --- a/lib/Model/Variable.php +++ b/lib/Model/Variable.php @@ -506,11 +506,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 8cf34f7..40e58f5 100644 --- a/test/Api/DevCycleClientTest.php +++ b/test/Api/DevCycleClientTest.php @@ -127,6 +127,7 @@ 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); @@ -228,6 +229,67 @@ public function testVariableWithMockedEvalResponse() 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'); } + + } From c6f90413604067afdc1a673d9ede8b6248bba15e Mon Sep 17 00:00:00 2001 From: Jason Salaber Date: Fri, 1 Aug 2025 12:01:51 -0400 Subject: [PATCH 8/8] fix: try to fix allVariables --- lib/Model/Variable.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/lib/Model/Variable.php b/lib/Model/Variable.php index 1153f8e..e35b286 100644 --- a/lib/Model/Variable.php +++ b/lib/Model/Variable.php @@ -92,7 +92,6 @@ public static function openAPIFormats(): array 'key' => 'key', 'type' => 'type', 'value' => 'value', - 'isDefaulted' => 'isDefaulted', 'eval' => 'eval' ]; @@ -106,7 +105,6 @@ public static function openAPIFormats(): array 'key' => 'setKey', 'type' => 'setType', 'value' => 'setValue', - 'isDefaulted' => 'setIsDefaulted', 'eval' => 'setEval' ]; @@ -120,7 +118,6 @@ public static function openAPIFormats(): array 'key' => 'getKey', 'type' => 'getType', 'value' => 'getValue', - 'isDefaulted' => 'getIsDefaulted', 'eval' => 'getEval' ];