diff --git a/.circleci/config.yml b/.circleci/config.yml index 838c713d..c2509694 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -53,6 +53,7 @@ jobs: environment: LD_INCLUDE_INTEGRATION_TESTS: 1 + TEST_HARNESS_PARAMS: -junit build/contract-tests-output/junit-results.xml docker: - image: cimg/php:<> @@ -90,10 +91,23 @@ jobs: - run: name: run tests command: php -d xdebug.mode=coverage vendor/bin/phpunit - enviroment: + environment: XDEBUG_MODE: coverage + - run: + name: build contract test service + command: make build-contract-tests + - run: + name: start contract test service + command: make start-contract-test-service + background: true + - run: + name: run contract tests + command: mkdir -p build/contract-tests-output && make run-contract-tests + - store_test_results: path: build/phpunit + - store_test_results: + path: build/contract-tests-output - store_artifacts: path: build/phpunit diff --git a/.gitignore b/.gitignore index e8a36213..a1cabfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /vendor/ +/test-service/vendor/ /doc/ *.iml composer.phar diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d9b85034..177e7954 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,3 +39,9 @@ By default, this test suite does not include any integration test that relies on ``` docker run --rm -p 8080:8080 wiremock/wiremock ``` + +To run the SDK contract test suite in Linux (see [`test-service/README.md`](./test-service/README.md)): + +```bash +make contract-tests +``` diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..b5ac5e88 --- /dev/null +++ b/Makefile @@ -0,0 +1,33 @@ + +TEMP_TEST_OUTPUT=/tmp/sse-contract-test-service.log + +# TEST_HARNESS_PARAMS can be set to add -skip parameters for any contract tests that cannot yet pass +# Explanation of current skips: +# - "secondary": In the PHP SDK this is not an addressable attribute for clauses; in other +# SDKs, it is. This was underspecified in the past; in future major versions, the other +# SDKs and the contract tests will be in line with the PHP behavior. +# - "date - bad syntax", "semver - bad type": The PHP SDK has insufficiently strict +# validation for these types. We will definitely fix this in 5.0 but may or may not +# address it in 4.x, since it does not prevent any valid values from working. +TEST_HARNESS_PARAMS := $(TEST_HARNESS_PARAMS) \ + -skip 'evaluation/parameterized/secondary' \ + -skip 'evaluation/parameterized/operators - date - bad syntax' \ + -skip 'evaluation/parameterized/operators - semver - bad type' + +build-contract-tests: + @cd test-service && composer install --no-progress + +start-contract-test-service: build-contract-tests + @cd test-service && php -S localhost:8000 index.php + +start-contract-test-service-bg: + @echo "Test service output will be captured in $(TEMP_TEST_OUTPUT)" + @make start-contract-test-service >$(TEMP_TEST_OUTPUT) 2>&1 & + +run-contract-tests: + @curl -s https://raw.githubusercontent.com/launchdarkly/sdk-test-harness/main/downloader/run.sh \ + | VERSION=v1 PARAMS="-url http://localhost:8000 -debug -stop-service-at-end $(TEST_HARNESS_PARAMS)" sh + +contract-tests: build-contract-tests start-contract-test-service-bg run-contract-tests + +.PHONY: build-contract-tests start-contract-test-service start-contract-test-service-bg run-contract-tests contract-tests diff --git a/src/LaunchDarkly/FeatureFlagsState.php b/src/LaunchDarkly/FeatureFlagsState.php index 12494791..de5cd70d 100644 --- a/src/LaunchDarkly/FeatureFlagsState.php +++ b/src/LaunchDarkly/FeatureFlagsState.php @@ -149,13 +149,17 @@ public function toValuesMap(): array public function jsonSerialize(): array { $ret = array_replace([], $this->_flagValues); - $metaMap = []; - foreach ($this->_flagMetadata as $key => $meta) { - $meta = array_replace([], $meta); - if (isset($meta['reason'])) { - $meta['reason'] = $meta['reason']->jsonSerialize(); + if (count($this->_flagMetadata) === 0) { + $metaMap = new \stdClass(); // using object rather than array ensures the JSON value is {}, not [] + } else { + $metaMap = []; + foreach ($this->_flagMetadata as $key => $meta) { + $meta = array_replace([], $meta); + if (isset($meta['reason'])) { + $meta['reason'] = $meta['reason']->jsonSerialize(); + } + $metaMap[$key] = $meta; } - $metaMap[$key] = $meta; } $ret['$flagsState'] = $metaMap; $ret['$valid'] = $this->_valid; diff --git a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php index 886048ed..7e4c4daa 100644 --- a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php @@ -41,10 +41,12 @@ public function __construct(string $sdkKey, array $options = []) { $this->_sdkKey = $sdkKey; - $eventsUri = LDClient::DEFAULT_EVENTS_URI; - if (isset($options['events_uri'])) { - $eventsUri = $options['events_uri']; + $baseUri = $options['events_uri'] ?? null; + if (!$baseUri) { + $baseUri = LDClient::DEFAULT_EVENTS_URI; } + $eventsUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); + $url = parse_url(rtrim($eventsUri, '/')); $this->_host = $url['host'] ?? ''; $this->_ssl = ($url['scheme'] ?? '') === 'https'; diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php index 0e0e978e..cca758fa 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php @@ -28,11 +28,13 @@ public function __construct(string $sdkKey, array $options = []) { $this->_sdkKey = $sdkKey; $this->_logger = $options['logger']; - if (isset($options['events_uri'])) { - $this->_eventsUri = $options['events_uri']; - } else { - $this->_eventsUri = LDClient::DEFAULT_EVENTS_URI; + + $baseUri = $options['events_uri'] ?? null; + if (!$baseUri) { + $baseUri = LDClient::DEFAULT_EVENTS_URI; } + $this->_eventsUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); + $this->_requestOptions = [ 'headers' => [ 'Content-Type' => 'application/json', @@ -53,7 +55,7 @@ public function publish(string $payload): bool try { $options = $this->_requestOptions; $options['body'] = $payload; - $response = $client->request('POST', '/bulk', $options); + $response = $client->request('POST', 'bulk', $options); } catch (\Exception $e) { $this->_logger->warning("GuzzleEventPublisher::publish caught $e"); return false; diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php index ed94f719..cde1b299 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -21,8 +21,8 @@ */ class GuzzleFeatureRequester implements FeatureRequester { - const SDK_FLAGS = "/sdk/flags"; - const SDK_SEGMENTS = "/sdk/segments"; + const SDK_FLAGS = "sdk/flags"; + const SDK_SEGMENTS = "sdk/segments"; /** @var Client */ private $_client; /** @var LoggerInterface */ @@ -32,6 +32,8 @@ class GuzzleFeatureRequester implements FeatureRequester public function __construct(string $baseUri, string $sdkKey, array $options) { + $baseUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); + $this->_logger = $options['logger']; $stack = HandlerStack::create(); if (class_exists('\Kevinrob\GuzzleCache\CacheMiddleware')) { diff --git a/src/LaunchDarkly/Impl/Util.php b/src/LaunchDarkly/Impl/Util.php index 1d2e8563..45f5f5c1 100644 --- a/src/LaunchDarkly/Impl/Util.php +++ b/src/LaunchDarkly/Impl/Util.php @@ -13,6 +13,14 @@ */ class Util { + public static function adjustBaseUri(string $uri): string + { + if (substr($uri, strlen($uri) - 1, 1) == '/') { + return $uri; + } + return $uri . '/'; // ensures that subpaths are concatenated correctly + } + public static function dateTimeToUnixMillis(DateTime $dateTime): int { $timeStampSeconds = (int)$dateTime->getTimestamp(); diff --git a/test-service/README.md b/test-service/README.md new file mode 100644 index 00000000..58b2c0bd --- /dev/null +++ b/test-service/README.md @@ -0,0 +1,9 @@ +# SDK contract test service + +This directory contains an implementation of the cross-platform SDK testing protocol defined by https://github.com/launchdarkly/sdk-test-harness. See that project's `README` for details of this protocol, and the kinds of SDK capabilities that are relevant to the contract tests. This code should not need to be updated unless the SDK has added or removed such capabilities. + +To run these tests locally, run `make contract-tests` from the SDK project root directory. This downloads the correct version of the test harness tool automatically. + +Or, to test against an in-progress local version of the test harness, run `make start-contract-test-service` from the SDK project root directory; then, in the root directory of the `sdk-test-harness` project, build the test harness and run it from the command line. + +The test service is designed to be run by any web server; PHP's built-in development server is adequate. The server must be configured to run `index.php` for all requests. The environment variable `LD_TEST_SERVICE_DATA_DIR` can be set, if desired, to point to a specific file path where the test service can store data; otherwise it will create a directory under `/tmp`. diff --git a/test-service/SdkClientEntity.php b/test-service/SdkClientEntity.php new file mode 100644 index 00000000..8b4af7fd --- /dev/null +++ b/test-service/SdkClientEntity.php @@ -0,0 +1,232 @@ +setFormatter(new Monolog\Formatter\LineFormatter( + "[%datetime%] %channel%.%level_name%: [$tag] %message%\n" + )); + $logger->pushHandler($stream); + $this->_logger = $logger; + + $this->_client = self::createSdkClient($params, $logger); + } + + public static function createSdkClient($params, $logger) + { + $config = $params['configuration']; + + $sdkKey = $config['credential']; + $options = [ + 'event_publisher' => LaunchDarkly\Integrations\Guzzle::eventPublisher(), + 'logger' => $logger + ]; + + $pollingConfig = $config['polling'] ?? []; + $options['base_uri'] = $pollingConfig['baseUri'] ?? null; + + $options['send_events'] = ($config['events'] ?? null) !== null; + $eventsConfig = $config['events'] ?? []; + $options['events_uri'] = $eventsConfig['baseUri'] ?? null; + $options['all_attributes_private'] = $eventsConfig['allAttributesPrivate'] ?? false; + $options['private_attribute_names'] = $eventsConfig['globalPrivateAttributes'] ?? null; + + return new LaunchDarkly\LDClient($sdkKey, $options); + } + + public function close() + { + // there isn't really any cleanup to do + $this->_logger->info('Test ended'); + } + + public function doCommand($reqParams) + { + $command = $reqParams['command']; + $commandParams = $reqParams[$command] ?? null; + switch ($command) { + case 'aliasEvent': + $this->doAliasEvent($commandParams); + return null; + + case 'customEvent': + $this->doCustomEvent($commandParams); + return null; + + case 'evaluate': + return $this->doEvaluate($commandParams); + + case 'evaluateAll': + return $this->doEvaluateAll($commandParams); + + case 'identifyEvent': + $this->doIdentifyEvent($commandParams); + return null; + + case 'flushEvents': + $this->_client->flush(); + return null; + + case 'secureModeHash': + return $this->doSecureModeHash($commandParams); + + default: + return false; // means invalid command + } + } + + private function doAliasEvent($params) + { + $this->_client->alias( + $this->makeUser($params['user']), + $this->makeUser($params['previousUser']) + ); + } + + private function doCustomEvent($params) + { + $this->_client->track( + $params['eventKey'], + $this->makeUser($params['user']), + $params['data'] ?? null, + $params['metricValue'] ?? null + ); + } + + private function doEvaluate($params) + { + $flagKey = $params['flagKey']; + $user = $this->makeUser($params['user']); + $defaultValue = $params['defaultValue'] ?? null; + $detail = $params['detail'] ?? false; + + if ($detail) { + $result = $this->_client->variationDetail($flagKey, $user, $defaultValue); + return [ + "value" => $result->getValue(), + "variationIndex" => $result->getVariationIndex(), + "reason" => $result->getReason() + ]; + } else { + $value = $this->_client->variation($flagKey, $user, $defaultValue); + return [ + "value" => $value + ]; + } + } + + private function doEvaluateAll($params) + { + $options = []; + foreach (['clientSideOnly', 'detailsOnlyForTrackedFlags', 'withReasons'] as $option) { + if ($params[$option] ?: false) { + $options[$option] = true; + } + } + $state = $this->_client->allFlagsState($this->makeUser($params['user']), $options); + return [ + 'state' => $state->jsonSerialize() + ]; + } + + private function doIdentifyEvent($params) + { + $this->_client->identify($this->makeUser($params['user'])); + } + + private function doSecureModeHash($params) + { + $user = $this->makeUser($params['user']); + $result = $this->_client->secureModeHash($user); + return [ + 'result' => $result + ]; + } + + private function makeUser($data) + { + $privateAttributeNames = $data['privateAttributeNames'] ?? []; + + $builder = new LaunchDarkly\LDUserBuilder(isset($data['key']) ? $data['key'] : null); + + $secondary = $data['secondary'] ?? null; + if (in_array('secondary', $privateAttributeNames)) { + $builder->privateSecondary($secondary); + } else { + $builder->secondary($secondary); + } + + $ip = $data['ip'] ?? null; + if (in_array('ip', $privateAttributeNames)) { + $builder->privateIp($ip); + } else { + $builder->ip($ip); + } + + $country = $data['country'] ?? null; + if (in_array('country', $privateAttributeNames)) { + $builder->privateCountry($country); + } else { + $builder->country($country); + } + + $email = $data['email'] ?? null; + if (in_array('email', $privateAttributeNames)) { + $builder->privateEmail($email); + } else { + $builder->email($email); + } + + $name = $data['name'] ?? null; + if (in_array('name', $privateAttributeNames)) { + $builder->privateName($name); + } else { + $builder->name($name); + } + + $avatar = $data['avatar'] ?? null; + if (in_array('avatar', $privateAttributeNames)) { + $builder->privateAvatar($avatar); + } else { + $builder->avatar($avatar); + } + + $firstName = $data['firstName'] ?? null; + if (in_array('firstName', $privateAttributeNames)) { + $builder->privateFirstName($firstName); + } else { + $builder->firstName($firstName); + } + + $lastName = $data['lastName'] ?? null; + if (in_array('lastName', $privateAttributeNames)) { + $builder->privateLastName($lastName); + } else { + $builder->lastName($lastName); + } + + if (isset($data['anonymous'])) { + $builder->anonymous($data['anonymous']); + } + + if (isset($data['custom'])) { + foreach ($data['custom'] as $key => $value) { + if (in_array($key, $privateAttributeNames)) { + $builder->privateCustomAttribute($key, $value); + } else { + $builder->customAttribute($key, $value); + } + } + } + + return $builder->build(); + } +} diff --git a/test-service/TestDataStore.php b/test-service/TestDataStore.php new file mode 100644 index 00000000..29201490 --- /dev/null +++ b/test-service/TestDataStore.php @@ -0,0 +1,45 @@ +_basePath = $basePath; + } + + public function addClientParams($params) + { + $data = json_encode($params); + + // call tempnam() to pick a random filename that doesn't already exist in our directory, + // and use that filename as the client ID from now on + $filePath = tempnam($this->_basePath, self::PREFIX); + file_put_contents($filePath, $data); + $id = substr_replace(basename($filePath), "", 0, strlen(self::PREFIX)); + + return $id; + } + + public function getClientParams($id) + { + $data = file_get_contents($this->getClientParamsFilePath($id)); + if ($data === false) { + return null; + } + return json_decode($data, true); + } + + public function deleteClientParams($id) + { + unlink($this->getClientParamsFilePath($id)); + } + + private function getClientParamsFilePath($id) + { + return $this->_basePath . '/' . self::PREFIX . $id; + } +} diff --git a/test-service/TestService.php b/test-service/TestService.php new file mode 100644 index 00000000..390ca9a6 --- /dev/null +++ b/test-service/TestService.php @@ -0,0 +1,100 @@ +_store = $store; + $this->_logger = $logger; + + $this->_app = new flight\Engine(); + $this->_app->set('flight.log_errors', true); + + $this->_app->route('GET /', function () { + $this->_app->json($this->getStatus()); + }); + + $this->_app->route('POST /', function () { + $params = $this->_app->request()->data; + $id = $this->createClient($params); + header("Location:/clients/$id"); + }); + + $this->_app->route('POST /clients/@id', function ($id) { + $c = $this->getClient($id); + if (!$c) { + http_response_code(404); + return; + } + $params = $this->_app->request()->data; + $resp = $c->doCommand($params); + if ($resp === false) { + http_response_code(400); + } elseif (is_array($resp)) { + $this->_app->json($resp); + } + }); + + $this->_app->route("DELETE /clients/@id", function ($id) { + if (!$this->deleteClient($id)) { + http_response_code(404); + } + }); + } + + public function start() + { + $this->_app->start(); + } + + public function getStatus() + { + return [ + 'name' => 'php-server-sdk', + 'capabilities' => [ + 'php', + 'server-side', + 'all-flags-client-side-only', + 'all-flags-details-only-for-tracked-flags', + 'all-flags-with-reasons', + 'secure-mode-hash' + ], + 'clientVersion' => \LaunchDarkly\LDClient::VERSION + ]; + } + + public function createClient($params) + { + $this->_logger->info("Creating client with parameters: " . json_encode($params)); + + $client = new SdkClientEntity($params, $this->_logger); // just to verify that the config is valid + + return $this->_store->addClientParams($params); + } + + public function deleteClient($id) + { + $c = $this->getClient($id); + if ($c) { + $c->close(); + $this->_store->deleteClientParams($id); + return true; + } + return false; + } + + private function getClient($id) + { + $params = $this->_store->getClientParams($id); + if ($params === null) { + return null; + } + return new SdkClientEntity($params); + } +} diff --git a/test-service/composer.json b/test-service/composer.json new file mode 100644 index 00000000..eb37b506 --- /dev/null +++ b/test-service/composer.json @@ -0,0 +1,20 @@ +{ + "repositories": [ + { + "type": "path", + "url": ".." + } + ], + "require": { + "doctrine/cache": "^1.0", + "guzzlehttp/guzzle": "^6.3 | ^7", + "kevinrob/guzzle-cache-middleware": "^4.0", + "launchdarkly/server-sdk": "*", + "mikecao/flight": "1.* | 2.*", + "monolog/monolog": "1.*", + "php": ">=7.3", + "psr/log": "1.*" + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/test-service/index.php b/test-service/index.php new file mode 100644 index 00000000..3c84a1e1 --- /dev/null +++ b/test-service/index.php @@ -0,0 +1,30 @@ +pushHandler(new Monolog\Handler\StreamHandler('php://stderr', Monolog\Logger::DEBUG)); + +$dataStorePath = getenv("LD_TEST_SERVICE_DATA_DIR"); +if (!$dataStorePath) { + $dataStorePath = '/tmp/php-server-sdk-test-service'; +} +if (!is_dir($dataStorePath)) { + if (!mkdir($dataStorePath, 0700, true)) { + return false; + } +} + +$store = new TestDataStore($dataStorePath); +$service = new TestService($store, $logger); +$service->start(); diff --git a/tests/FeatureFlagsStateTest.php b/tests/FeatureFlagsStateTest.php index b6473c22..c2abfade 100644 --- a/tests/FeatureFlagsStateTest.php +++ b/tests/FeatureFlagsStateTest.php @@ -138,4 +138,20 @@ public function testJsonEncodeUsesCustomSerializer() $decoded = json_decode($json, true); $this->assertEquals($expected, $decoded); } + + public function testJsonEncodeWithEmptyData() + { + $state = new FeatureFlagsState(true); + $json = json_encode($state); + + $expected = [ + '$valid' => true, + '$flagsState' => [] + ]; + $this->assertEquals($expected, json_decode($json, true)); + + // Due to ambiguity of PHP array types, we need to verify that the $flagsState value + // is an empty JSON object, not an empty JSON array. + $this->assertStringContainsString('"$flagsState":{}', $json); + } }