diff --git a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php index 7e4c4daa..305fbb5f 100644 --- a/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/CurlEventPublisher.php @@ -3,6 +3,7 @@ namespace LaunchDarkly\Impl\Integrations; use LaunchDarkly\EventPublisher; +use LaunchDarkly\Impl\Util; use LaunchDarkly\LDClient; /** @@ -13,9 +14,6 @@ */ class CurlEventPublisher implements EventPublisher { - /** @var string */ - private $_sdkKey; - /** @var string */ private $_host; @@ -37,10 +35,11 @@ class CurlEventPublisher implements EventPublisher /** @var bool */ private $_isWindows; + /** @var array */ + private $_eventHeaders; + public function __construct(string $sdkKey, array $options = []) { - $this->_sdkKey = $sdkKey; - $baseUri = $options['events_uri'] ?? null; if (!$baseUri) { $baseUri = LDClient::DEFAULT_EVENTS_URI; @@ -61,6 +60,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'; } @@ -92,11 +92,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; @@ -114,16 +118,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 cca758fa..bc4af802 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleEventPublisher.php @@ -36,13 +36,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 cde1b299..25ad6b9a 100644 --- a/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php +++ b/src/LaunchDarkly/Impl/Integrations/GuzzleFeatureRequester.php @@ -12,7 +12,6 @@ use LaunchDarkly\Impl\Model\Segment; use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Impl\Util; -use LaunchDarkly\LDClient; use Psr\Log\LoggerInterface; /** @@ -49,11 +48,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 45f5f5c1..ef40a174 100644 --- a/src/LaunchDarkly/Impl/Util.php +++ b/src/LaunchDarkly/Impl/Util.php @@ -4,6 +4,9 @@ use DateTime; use DateTimeZone; +use LaunchDarkly\EventPublisher; +use LaunchDarkly\LDClient; +use LaunchDarkly\Types\ApplicationInfo; /** * Internal class containing helper methods. @@ -48,4 +51,47 @@ public static function httpErrorMessage(int $status, string $context, string $re . ' for ' . $context . ' - ' . (Util::isHttpErrorRecoverable($status) ? $retryMessage : 'giving up permanently'); } + + /** + * 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 8a28e725..afcac8bc 100644 --- a/src/LaunchDarkly/LDClient.php +++ b/src/LaunchDarkly/LDClient.php @@ -9,6 +9,7 @@ use LaunchDarkly\Impl\PreloadedFeatureRequester; use LaunchDarkly\Impl\UnrecoverableHTTPStatusException; use LaunchDarkly\Integrations\Guzzle; +use LaunchDarkly\Types\ApplicationInfo; use Monolog\Handler\ErrorLogHandler; use Monolog\Logger; use Psr\Log\LoggerInterface; @@ -81,6 +82,7 @@ class LDClient * - `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 * per-user basis in LDUserBuilder. + * - `application_info`: An optional {@see \LaunchDarkly\Types\ApplicationInfo} instance. * - Other options may be available depending on any features you are using from the `LaunchDarkly\Integrations` namespace. * * @return LDClient @@ -125,8 +127,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 332d7401..cd99db9d 100644 --- a/tests/Impl/Integrations/CurlEventPublisherTest.php +++ b/tests/Impl/Integrations/EventPublisherTest.php @@ -6,9 +6,11 @@ use LaunchDarkly\EventPublisher; use LaunchDarkly\Impl\Integrations; use LaunchDarkly\LDClient; +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"); + } +}