diff --git a/.ldrelease/config.yml b/.ldrelease/config.yml index 3fbf486a..7604660e 100644 --- a/.ldrelease/config.yml +++ b/.ldrelease/config.yml @@ -8,6 +8,11 @@ publications: - url: https://packagist.org/packages/launchdarkly/server-sdk description: Packagist +branches: + - name: main + description: 5.x + - name: 4.x + jobs: - docker: image: ldcircleci/php-sdk-release:4 # Releaser's default for PHP is still php-sdk-release:3, which is PHP 7.x diff --git a/CHANGELOG.md b/CHANGELOG.md index 6369da04..ac3613ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ All notable changes to the LaunchDarkly PHP SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [4.3.0] - 2023-01-31 +### Added: +- Introduced support for an `application_info` config property which sets application metadata that may be used in LaunchDarkly analytics or other product features. This does not affect feature flag evaluations. + ## [5.0.0] - 2023-01-04 The latest version of this SDK supports LaunchDarkly's new custom contexts feature. Contexts are an evolution of a previously-existing concept, "users." Contexts let you create targeting rules for feature flags based on a variety of different information, including attributes pertaining to users, organizations, devices, and more. You can even combine contexts to create "multi-contexts." diff --git a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php index 179e5444..0d6a293a 100644 --- a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php @@ -4,6 +4,7 @@ namespace LaunchDarkly\Impl\Integrations; +use LaunchDarkly\Impl\Util; use LaunchDarkly\LDClient; use LaunchDarkly\Subsystems\EventPublisher; @@ -15,7 +16,6 @@ */ class CurlEventPublisher implements EventPublisher { - private string $_sdkKey; private string $_host; private int $_port; private string $_path; @@ -24,10 +24,11 @@ class CurlEventPublisher implements EventPublisher private int $_connectTimeout; private bool $_isWindows; + /** @var array */ + private array $_eventHeaders; + public function __construct(string $sdkKey, array $options = []) { - $this->_sdkKey = $sdkKey; - $baseUri = $options['events_uri'] ?? null; if (!$baseUri) { $baseUri = LDClient::DEFAULT_EVENTS_URI; @@ -48,6 +49,7 @@ public function __construct(string $sdkKey, array $options = []) $this->_curl = $options['curl']; } + $this->_eventHeaders = Util::eventHeaders($sdkKey, $options['application_info'] ?? null); $this->_connectTimeout = $options['connect_timeout']; $this->_isWindows = PHP_OS_FAMILY == 'Windows'; } @@ -79,11 +81,15 @@ private function createCurlArgs(string $payload): string $scheme = $this->_ssl ? "https://" : "http://"; $args = " -X POST"; $args.= " --connect-timeout " . $this->_connectTimeout; - $args.= " -H 'Content-Type: application/json'"; - $args.= " -H " . escapeshellarg("Authorization: " . $this->_sdkKey); - $args.= " -H 'User-Agent: PHPClient/" . LDClient::VERSION . "'"; - $args.= " -H 'X-LaunchDarkly-Event-Schema: " . EventPublisher::CURRENT_SCHEMA_VERSION . "'"; - $args.= " -H 'Accept: application/json'"; + + foreach ($this->_eventHeaders as $key => $value) { + if ($key == 'Authorization') { + $args.= " -H " . escapeshellarg("Authorization: " . $value); + } else { + $args.= " -H '$key: $value'"; + } + } + $args.= " -d " . escapeshellarg($payload); $args.= " " . escapeshellarg($scheme . $this->_host . ":" . $this->_port . $this->_path . "/bulk"); return $args; @@ -101,16 +107,8 @@ private function makeCurlRequest(string $args): bool private function createPowershellArgs(string $payloadFile): string { - $headers = [ - 'Content-Type' => 'application/json', - 'Authorization' => $this->_sdkKey, - 'User-Agent' => 'PHPClient/' . LDClient::VERSION, - 'X-LaunchDarkly-Event-Schema' => EventPublisher::CURRENT_SCHEMA_VERSION, - 'Accept' => 'application/json', - ]; - $headerString = ""; - foreach ($headers as $key => $value) { + foreach ($this->_eventHeaders as $key => $value) { $headerString .= sprintf("'%s'='%s';", $key, $value); } diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php index e1f6f312..2128ec84 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php @@ -34,13 +34,7 @@ public function __construct(string $sdkKey, array $options = []) $this->_eventsUri = \LaunchDarkly\Impl\Util::adjustBaseUri($baseUri); $this->_requestOptions = [ - 'headers' => [ - 'Content-Type' => 'application/json', - 'Authorization' => $this->_sdkKey, - 'User-Agent' => 'PHPClient/' . LDClient::VERSION, - 'Accept' => 'application/json', - 'X-LaunchDarkly-Event-Schema' => strval(EventPublisher::CURRENT_SCHEMA_VERSION) - ], + 'headers' => Util::eventHeaders($this->_sdkKey, $options['application_info'] ?? null), 'timeout' => $options['timeout'], 'connect_timeout' => $options['connect_timeout'] ]; diff --git a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php index 6eb54f30..47d3efec 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -13,7 +13,6 @@ use LaunchDarkly\Impl\Model\Segment; use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Impl\Util; -use LaunchDarkly\LDClient; use LaunchDarkly\Subsystems\FeatureRequester; use Psr\Log\LoggerInterface; @@ -48,11 +47,7 @@ public function __construct(string $baseUri, string $sdkKey, array $options) } $defaults = [ - 'headers' => [ - 'Authorization' => $sdkKey, - 'Content-Type' => 'application/json', - 'User-Agent' => 'PHPClient/' . LDClient::VERSION - ], + 'headers' => Util::defaultHeaders($sdkKey, $options['application_info'] ?? null), 'timeout' => $options['timeout'], 'connect_timeout' => $options['connect_timeout'], 'handler' => $stack, diff --git a/src/LaunchDarkly/Impl/Util.php b/src/LaunchDarkly/Impl/Util.php index 29dc720d..a0350279 100644 --- a/src/LaunchDarkly/Impl/Util.php +++ b/src/LaunchDarkly/Impl/Util.php @@ -6,6 +6,9 @@ use DateTime; use DateTimeZone; +use LaunchDarkly\LDClient; +use LaunchDarkly\Subsystems\EventPublisher; +use LaunchDarkly\Types\ApplicationInfo; use Monolog\Handler\NullHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -64,4 +67,47 @@ public static function makeNullLogger(): LoggerInterface { return new Logger('', [new NullHandler()]); } + + /** + * An array of header name and values that should be used for any request + * made to LaunchDarkly servers. + * + * @param string $sdkKey + * @param ApplicationInfo|null $applicationInfo + * @return array + */ + public static function defaultHeaders(string $sdkKey, $applicationInfo): array + { + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + 'Authorization' => $sdkKey, + 'User-Agent' => 'PHPClient/' . LDClient::VERSION, + ]; + + if ($applicationInfo instanceof ApplicationInfo) { + $headerValue = (string) $applicationInfo; + if ($headerValue) { + $headers['X-LaunchDarkly-Tags'] = $headerValue; + } + } + + return $headers; + } + + /** + * An array of header name and values that should be used for any request + * made to the LaunchDarkly Events API. + * + * @param string $sdkKey + * @param ApplicationInfo|null $applicationInfo + * @return array + */ + public static function eventHeaders(string $sdkKey, $applicationInfo): array + { + $headers = Util::defaultHeaders($sdkKey, $applicationInfo); + $headers['X-LaunchDarkly-Event-Schema'] = EventPublisher::CURRENT_SCHEMA_VERSION; + + return $headers; + } } diff --git a/src/LaunchDarkly/LDClient.php b/src/LaunchDarkly/LDClient.php index ea135abe..288a2b40 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -16,6 +16,7 @@ use LaunchDarkly\Impl\Util; use LaunchDarkly\Integrations\Guzzle; use LaunchDarkly\Subsystems\FeatureRequester; +use LaunchDarkly\Types\ApplicationInfo; use Monolog\Handler\ErrorLogHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -75,6 +76,7 @@ class LDClient * Defaults to false. * - `private_attribute_names`: An optional array of user attribute names to be marked private. Any users sent to LaunchDarkly * with this configuration active will have attributes with these names removed. You can also set private attributes on a + * - `application_info`: An optional {@see \LaunchDarkly\Types\ApplicationInfo} instance. * per-user basis in the LDContext builder. * - Other options may be available depending on any features you are using from the `LaunchDarkly\Integrations` namespace. * @@ -120,8 +122,17 @@ public function __construct(string $sdkKey, array $options = []) $logger = new Logger("LaunchDarkly", [new ErrorLogHandler()]); $options['logger'] = $logger; } + + /** @var LoggerInterface */ $this->_logger = $options['logger']; + $applicationInfo = $options['application_info'] ?? null; + if ($applicationInfo instanceof ApplicationInfo) { + foreach ($applicationInfo->errors() as $error) { + $this->_logger->warning($error); + } + } + $this->_eventFactoryDefault = new EventFactory(false); $this->_eventFactoryWithReasons = new EventFactory(true); diff --git a/src/LaunchDarkly/Types/ApplicationInfo.php b/src/LaunchDarkly/Types/ApplicationInfo.php new file mode 100644 index 00000000..6ae77e3b --- /dev/null +++ b/src/LaunchDarkly/Types/ApplicationInfo.php @@ -0,0 +1,100 @@ +id = null; + $this->version = null; + $this->errors = []; + } + + /** + * Set the application id metadata identifier. + */ + public function withId(string $id): ApplicationInfo + { + $this->id = $this->validateValue($id, 'id'); + + return $this; + } + + /** + * Set the application version metadata identifier. + */ + public function withVersion(string $version): ApplicationInfo + { + $this->version = $this->validateValue($version, 'version'); + + return $this; + } + + /** + * Retrieve any validation errors that have accumulated as a result of creating this instance. + */ + public function errors(): array + { + return array_values($this->errors); + } + + public function __toString(): string + { + $parts = []; + + if ($this->id !== null) { + $parts[] = "application-id/{$this->id}"; + } + + if ($this->version !== null) { + $parts[] = "application-version/{$this->version}"; + } + + return join(" ", $parts); + } + + private function validateValue(string $value, string $label): ?string + { + $value = strval($value); + + if ($value === '') { + return null; + } + + if (strlen($value) > 64) { + $this->errors[$label] = "Application value for $label was longer than 64 characters and was discarded"; + return null; + } + + if (preg_match('/[^a-zA-Z0-9._-]/', $value)) { + $this->errors[$label] = "Application value for $label contained invalid characters and was discarded"; + return null; + } + + return $value; + } +} diff --git a/tests/Impl/Integrations/CurlEventPublisherTest.php b/tests/Impl/Integrations/EventPublisherTest.php similarity index 64% rename from tests/Impl/Integrations/CurlEventPublisherTest.php rename to tests/Impl/Integrations/EventPublisherTest.php index c4397be4..090a2d1c 100644 --- a/tests/Impl/Integrations/CurlEventPublisherTest.php +++ b/tests/Impl/Integrations/EventPublisherTest.php @@ -6,9 +6,11 @@ use LaunchDarkly\Impl\Integrations; use LaunchDarkly\LDClient; use LaunchDarkly\Subsystems\EventPublisher; +use LaunchDarkly\Types\ApplicationInfo; use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; -class CurlEventPublisherTest extends TestCase +class EventPublisherTest extends TestCase { public function setUp(): void { @@ -20,16 +22,35 @@ public function setUp(): void $client->request('DELETE', 'http://localhost:8080/__admin/requests'); } - public function testSendsCorrectBodyAndHeaders() + public function getEventPublisher(): array + { + /** @var LoggerInterface **/ + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + $appInfo = (new ApplicationInfo())->withId('my-id')->withVersion('my-version'); + + $config = [ + 'events_uri' => 'http://localhost:8080', + 'timeout' => 3, + 'connect_timeout' => 3, + 'application_info' => $appInfo, + 'logger' => $logger, + ]; + + $curlPublisher = new Integrations\CurlEventPublisher('sdk-key', $config); + $guzzlePublisher = new Integrations\GuzzleEventPublisher('sdk-key', $config); + + return [ + [$curlPublisher], + [$guzzlePublisher], + ]; + } + + /** + * @dataProvider getEventPublisher + */ + public function testSendsCorrectBodyAndHeaders($publisher) { $event = json_encode(["key" => "user-key"]); - $publisher = new Integrations\CurlEventPublisher( - 'sdk-key', - [ - 'events_uri' => 'http://localhost:8080', - 'connect_timeout' => 3, - ] - ); $publisher->publish($event); $requests = []; @@ -67,5 +88,6 @@ public function testSendsCorrectBodyAndHeaders() $this->assertEquals('sdk-key', $headers['Authorization']); $this->assertEquals('PHPClient/' . LDClient::VERSION, $headers['User-Agent']); $this->assertEquals(EventPublisher::CURRENT_SCHEMA_VERSION, $headers['X-LaunchDarkly-Event-Schema']); + $this->assertEquals('application-id/my-id application-version/my-version', $headers['X-LaunchDarkly-Tags']); } } diff --git a/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php new file mode 100644 index 00000000..4c2badf5 --- /dev/null +++ b/tests/Impl/Integrations/GuzzleFeatureRequesterTest.php @@ -0,0 +1,75 @@ +markTestSkipped("Skipping integration test"); + } + + $client = new Client(); + $client->request('DELETE', 'http://localhost:8080/__admin/requests'); + } + + public function testSendsCorrectHeaders(): void + { + /** @var LoggerInterface **/ + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + $appInfo = (new ApplicationInfo())->withId('my-id')->withVersion('my-version'); + + $config = [ + 'logger' => $logger, + 'timeout' => 3, + 'connect_timeout' => 3, + 'application_info' => $appInfo, + ]; + + $requester = new GuzzleFeatureRequester('http://localhost:8080', 'sdk-key', $config); + $requester->getFeature("flag-key"); + + $requests = []; + $client = new Client(); + + // Provide time for the curl to execute + $start = time(); + while (time() - $start < 5) { + $response = $client->request('GET', 'http://localhost:8080/__admin/requests'); + $body = json_decode($response->getBody()->getContents(), true); + $requests = $body['requests']; + + if ($requests) { + break; + } + usleep(100); + } + + if (!$requests) { + $this->fail("Unable to connect to endpoint within specified timeout"); + } + + $this->assertCount(1, $requests); + + $request = $requests[0]['request']; + + // Validate that we hit the right endpoint + $this->assertEquals('/sdk/flags/flag-key', $request['url']); + + // And validate that we provided all the correct headers + $headers = $request['headers']; + $this->assertEquals('application/json', $headers['Content-Type']); + $this->assertEquals('application/json', $headers['Accept']); + $this->assertEquals('sdk-key', $headers['Authorization']); + $this->assertEquals('PHPClient/' . LDClient::VERSION, $headers['User-Agent']); + $this->assertEquals('application-id/my-id application-version/my-version', $headers['X-LaunchDarkly-Tags']); + } +} diff --git a/tests/Types/ApplicationInfoTest.php b/tests/Types/ApplicationInfoTest.php new file mode 100644 index 00000000..9f232552 --- /dev/null +++ b/tests/Types/ApplicationInfoTest.php @@ -0,0 +1,79 @@ +appInfo = new ApplicationInfo(); + } + + public function testNewInstanceIsEmpty(): void + { + $this->assertEquals((string) $this->appInfo, "", "Empty app info isn't empty!"); + } + + public function testCanSetValuesAsExpected(): void + { + $this->appInfo + ->withId("my-id") + ->withVersion("my-version"); + + $this->assertEquals("application-id/my-id application-version/my-version", (string) $this->appInfo, "Failed to set id and version correctly"); + $this->assertEmpty($this->appInfo->errors()); + } + + public function testIgnoresEmptyValues(): void + { + $this->appInfo->withId("")->withVersion(""); + + $this->assertEquals("", (string) $this->appInfo, "Failed to set id and version correctly"); + $this->assertEquals([], $this->appInfo->errors(), "Failed to set id and version correctly"); + } + + /** + * @return array> + */ + public function invalidValues(): array + { + return [ + [' ', 'Application value for %s contained invalid characters and was discarded'], + [' ', 'Application value for %s contained invalid characters and was discarded'], + ['@', 'Application value for %s contained invalid characters and was discarded'], + ['@', 'Application value for %s contained invalid characters and was discarded'], + ['abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_a', 'Application value for %s was longer than 64 characters and was discarded'], // Too long + ]; + } + + /** + * @dataProvider invalidValues + */ + public function testIgnoresInvalidValuesAndLogsAppropriately(string $value, string $error): void + { + $this->appInfo->withId($value)->withVersion($value); + + $errors = [ + sprintf($error, 'id'), + sprintf($error, 'version'), + ]; + + $this->assertEquals("", (string) $this->appInfo, "Failed to set id and version correctly"); + $this->assertEquals($errors, $this->appInfo->errors(), "Failed to set id and version correctly"); + } + + public function testOnlyTracksMostRecentFailure(): void + { + $this->appInfo->withId('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_a'); + $this->assertEquals(["Application value for id was longer than 64 characters and was discarded"], $this->appInfo->errors(), "Most recent error wasn't retained"); + + $this->appInfo->withId('@'); + $this->assertEquals(["Application value for id contained invalid characters and was discarded"], $this->appInfo->errors(), "Most recent error wasn't retained"); + } +}