From 3fffdcdd48e1b8a3b9e87bff96e05091ef0765bc Mon Sep 17 00:00:00 2001 From: "Mark A. Hershberger" Date: Sun, 20 Sep 2026 21:46:55 -0400 Subject: [PATCH 1/2] Add Configuration API to the HTTP client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Dapr Configuration building block over HTTP: - DaprClient::getConfiguration() / getConfigurationAsync() — read items from a configuration store (GET /v1.0/configuration/), returning a map of key => ConfigurationItem. - DaprClient::subscribeConfiguration() / subscribeConfigurationAsync() — subscribe to changes (GET /v1.0/configuration//subscribe), returning the subscription id. - DaprClient::unsubscribeConfiguration() / unsubscribeConfigurationAsync() — unsubscribe (GET /v1.0/configuration///unsubscribe). - Configuration\ConfigurationItem value object (value/version/metadata). - Configuration\ConfigurationUpdate::parse() to decode the update payload the sidecar POSTs to the app's /configuration/ route. Keys are sent as repeated 'key=' query params (built manually since Dapr does not accept PHP's key[0]= encoding). ConfigurationItem uses plain property defaults rather than constructor promotion because the deserializer instantiates it without the constructor and stores may omit version/metadata. Adds HttpConfigurationTrait, wires it into DaprHttpClient, declares the abstract methods on DaprClient, covers it with 8 unit tests, and documents it in the readme. Verified end-to-end against a live sidecar with the configuration.redis store (get, subscribe, pushed update, unsubscribe). Signed-off-by: Mark A. Hershberger --- readme.md | 23 +++ src/lib/Client/DaprClient.php | 68 ++++++++ src/lib/Client/DaprHttpClient.php | 1 + src/lib/Client/HttpConfigurationTrait.php | 97 ++++++++++++ src/lib/Configuration/ConfigurationItem.php | 32 ++++ src/lib/Configuration/ConfigurationUpdate.php | 54 +++++++ tests/ConfigurationTest.php | 146 ++++++++++++++++++ 7 files changed, 421 insertions(+) create mode 100644 src/lib/Client/HttpConfigurationTrait.php create mode 100644 src/lib/Configuration/ConfigurationItem.php create mode 100644 src/lib/Configuration/ConfigurationUpdate.php create mode 100644 tests/ConfigurationTest.php diff --git a/readme.md b/readme.md index be42dec..b3e7d61 100644 --- a/readme.md +++ b/readme.md @@ -79,6 +79,29 @@ $client->getSecret(storeName: 'kubernetes', key: 'test'); $client->getBulkSecret(storeName: 'kubernetes'); ``` +# Accessing Configuration + +You can read configuration items from a configuration store and subscribe to changes: + +```php +getConfiguration(storeName: 'configstore', keys: ['orderId1', 'orderId2']); +foreach ($items as $key => $item) { + // $item is a \Dapr\Configuration\ConfigurationItem with ->value, ->version, ->metadata +} + +// subscribe to changes; returns a subscription id. The sidecar pushes updates to your +// app's `/configuration/` route. +$id = $client->subscribeConfiguration(storeName: 'configstore', keys: ['orderId1']); + +// later, unsubscribe +$client->unsubscribeConfiguration(storeName: 'configstore', id: $id); +``` + +Parse pushed updates in your app route with `\Dapr\Configuration\ConfigurationUpdate::parse($json)`. + # Accessing State There are several ways to access state. You can access state directly via the client or abstract access via an object. diff --git a/src/lib/Client/DaprClient.php b/src/lib/Client/DaprClient.php index f0834dd..134d3ee 100644 --- a/src/lib/Client/DaprClient.php +++ b/src/lib/Client/DaprClient.php @@ -5,6 +5,7 @@ use Dapr\Actors\IActorReference; use Dapr\Actors\Reminder; use Dapr\Actors\Timer; +use Dapr\Configuration\ConfigurationItem; use Dapr\consistency\Consistency; use Dapr\Deserialization\DeserializationConfig; use Dapr\Deserialization\IDeserializer; @@ -444,6 +445,73 @@ abstract public function getBulkSecretAsync(string $storeName, array $metadata = */ abstract public function getBulkSecret(string $storeName, array $metadata = []): array; + /** + * Get configuration items from a configuration store. + * + * @param string $storeName The name of the configuration store + * @param array $keys The keys of the configuration items to get; all items if empty + * @param array $metadata Optional metadata passed to the configuration store + * + * @return array The configuration items, keyed by their configuration key + */ + abstract public function getConfiguration(string $storeName, array $keys = [], array $metadata = []): array; + + /** + * @param string $storeName + * @param array $keys + * @param array $metadata + * + * @return PromiseInterface> + */ + abstract public function getConfigurationAsync( + string $storeName, + array $keys = [], + array $metadata = [] + ): PromiseInterface; + + /** + * Subscribe to configuration changes in a configuration store. The sidecar pushes updates to the + * application's `/configuration/` route; parse them with ConfigurationUpdate::parse(). + * + * @param string $storeName The name of the configuration store + * @param array $keys The keys to subscribe to; all keys if empty + * @param array $metadata Optional metadata passed to the configuration store + * + * @return string The subscription id, used to unsubscribe + */ + abstract public function subscribeConfiguration(string $storeName, array $keys = [], array $metadata = []): string; + + /** + * @param string $storeName + * @param array $keys + * @param array $metadata + * + * @return PromiseInterface + */ + abstract public function subscribeConfigurationAsync( + string $storeName, + array $keys = [], + array $metadata = [] + ): PromiseInterface; + + /** + * Unsubscribe from configuration changes. + * + * @param string $storeName The name of the configuration store + * @param string $id The subscription id returned by subscribeConfiguration() + * + * @return bool True if the unsubscription succeeded + */ + abstract public function unsubscribeConfiguration(string $storeName, string $id): bool; + + /** + * @param string $storeName + * @param string $id + * + * @return PromiseInterface + */ + abstract public function unsubscribeConfigurationAsync(string $storeName, string $id): PromiseInterface; + /** * Check if the daprd instance is up and running. * diff --git a/src/lib/Client/DaprHttpClient.php b/src/lib/Client/DaprHttpClient.php index c8e2aa5..56980ba 100644 --- a/src/lib/Client/DaprHttpClient.php +++ b/src/lib/Client/DaprHttpClient.php @@ -16,6 +16,7 @@ class DaprHttpClient extends DaprClient { use HttpStateTrait; use HttpSecretsTrait; + use HttpConfigurationTrait; use HttpInvokeTrait; use HttpPubSubTrait; use HttpBindingTrait; diff --git a/src/lib/Client/HttpConfigurationTrait.php b/src/lib/Client/HttpConfigurationTrait.php new file mode 100644 index 0000000..1350b46 --- /dev/null +++ b/src/lib/Client/HttpConfigurationTrait.php @@ -0,0 +1,97 @@ + + */ + public function getConfiguration(string $storeName, array $keys = [], array $metadata = []): array + { + return $this->getConfigurationAsync($storeName, $keys, $metadata)->wait(); + } + + /** + * @return PromiseInterface> + */ + public function getConfigurationAsync(string $storeName, array $keys = [], array $metadata = []): PromiseInterface + { + $storeName = rawurlencode($storeName); + $options = $this->buildConfigurationOptions($keys, $metadata); + return $this->handlePromise( + $this->httpClient->getAsync("/v1.0/configuration/$storeName", $options), + fn(ResponseInterface $response) => $this->deserializer->from_array_of( + ConfigurationItem::class, + json_decode($response->getBody()->getContents(), true) ?? [] + ) + ); + } + + public function subscribeConfiguration(string $storeName, array $keys = [], array $metadata = []): string + { + return $this->subscribeConfigurationAsync($storeName, $keys, $metadata)->wait(); + } + + public function subscribeConfigurationAsync( + string $storeName, + array $keys = [], + array $metadata = [] + ): PromiseInterface { + $storeName = rawurlencode($storeName); + $options = $this->buildConfigurationOptions($keys, $metadata); + return $this->handlePromise( + $this->httpClient->getAsync("/v1.0/configuration/$storeName/subscribe", $options), + fn(ResponseInterface $response) => json_decode($response->getBody()->getContents(), true)['id'] + ); + } + + public function unsubscribeConfiguration(string $storeName, string $id): bool + { + return $this->unsubscribeConfigurationAsync($storeName, $id)->wait(); + } + + public function unsubscribeConfigurationAsync(string $storeName, string $id): PromiseInterface + { + $storeName = rawurlencode($storeName); + $id = rawurlencode($id); + return $this->handlePromise( + $this->httpClient->getAsync("/v1.0/configuration/$storeName/$id/unsubscribe"), + fn(ResponseInterface $response) => json_decode($response->getBody()->getContents(), true)['ok'] ?? false + ); + } + + /** + * Build the guzzle options for a configuration request. Keys are sent as repeated + * `key=` query parameters, which Dapr expects, so the query string is built manually. + * + * @param array $keys + * @param array $metadata + * + * @return array + */ + private function buildConfigurationOptions(array $keys, array $metadata): array + { + $params = array_map(fn($key) => 'key=' . rawurlencode($key), $keys); + foreach ($metadata as $key => $value) { + $params[] = 'metadata.' . rawurlencode($key) . '=' . rawurlencode($value); + } + + return empty($params) ? [] : ['query' => implode('&', $params)]; + } +} diff --git a/src/lib/Configuration/ConfigurationItem.php b/src/lib/Configuration/ConfigurationItem.php new file mode 100644 index 0000000..3a86470 --- /dev/null +++ b/src/lib/Configuration/ConfigurationItem.php @@ -0,0 +1,32 @@ +` route after subscribing via + * DaprClient::subscribeConfiguration(). + * + * @package Dapr\Configuration + */ +class ConfigurationUpdate +{ + /** + * ConfigurationUpdate constructor. + * + * @param string $id The subscription id this update belongs to + * @param ConfigurationItem[] $items The updated configuration items, keyed by their configuration key + */ + public function __construct( + public string $id = '', + public array $items = [] + ) { + } + + /** + * Parse the raw JSON body of a configuration update delivered by the sidecar. + * + * @param string $json The raw JSON body + * + * @return ConfigurationUpdate The parsed update + */ + public static function parse(string $json): ConfigurationUpdate + { + $raw = json_decode($json, true) ?? []; + $update = new ConfigurationUpdate(); + $update->id = $raw['id'] ?? ''; + $update->items = array_map( + function (array $item) { + $configItem = new ConfigurationItem(); + $configItem->value = $item['value'] ?? ''; + $configItem->version = $item['version'] ?? ''; + $configItem->metadata = $item['metadata'] ?? null; + + return $configItem; + }, + $raw['items'] ?? [] + ); + + return $update; + } +} diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php new file mode 100644 index 0000000..200df21 --- /dev/null +++ b/tests/ConfigurationTest.php @@ -0,0 +1,146 @@ +get_http_client_stack( + [ + new Response( + 200, + body: json_encode( + [ + 'orderId1' => ['value' => '101', 'version' => 'v1', 'metadata' => null], + 'orderId2' => ['value' => '102', 'version' => '', 'metadata' => ['env' => 'test']], + ] + ) + ), + ] + ); + $client = $this->get_new_client_with_http($stack->client); + + /** @var ConfigurationItem[] $result */ + $result = $client->getConfiguration('configstore', ['orderId1', 'orderId2']); + + $request = $this->get_last_request($stack); + $this->assertRequestMethod('GET', $request); + $this->assertRequestUri('/v1.0/configuration/configstore', $request); + $this->assertRequestQueryString('key=orderId1&key=orderId2', $request); + + $this->assertCount(2, $result); + $this->assertInstanceOf(ConfigurationItem::class, $result['orderId1']); + $this->assertSame('101', $result['orderId1']->value); + $this->assertSame('v1', $result['orderId1']->version); + $this->assertNull($result['orderId1']->metadata); + $this->assertSame('102', $result['orderId2']->value); + $this->assertSame(['env' => 'test'], $result['orderId2']->metadata); + } + + public function testGetConfigurationAllKeys(): void + { + $stack = $this->get_http_client_stack([new Response(200, body: '{}')]); + $client = $this->get_new_client_with_http($stack->client); + + $result = $client->getConfiguration('configstore'); + + $request = $this->get_last_request($stack); + $this->assertRequestUri('/v1.0/configuration/configstore', $request); + $this->assertRequestQueryString('', $request); + $this->assertSame([], $result); + } + + public function testGetConfigurationWithMetadata(): void + { + $stack = $this->get_http_client_stack([new Response(200, body: '{}')]); + $client = $this->get_new_client_with_http($stack->client); + + $client->getConfiguration('configstore', ['orderId1'], ['ttl' => '10']); + + $request = $this->get_last_request($stack); + $this->assertRequestQueryString('key=orderId1&metadata.ttl=10', $request); + } + + public function testGetConfigurationSparseItem(): void + { + $stack = $this->get_http_client_stack( + [new Response(200, body: json_encode(['orderId1' => ['value' => '101']]))] + ); + $client = $this->get_new_client_with_http($stack->client); + + $result = $client->getConfiguration('configstore', ['orderId1']); + + $this->assertSame('101', $result['orderId1']->value); + $this->assertSame('', $result['orderId1']->version); + $this->assertNull($result['orderId1']->metadata); + } + + public function testSubscribeConfiguration(): void + { + $stack = $this->get_http_client_stack([new Response(200, body: json_encode(['id' => 'sub-id']))]); + $client = $this->get_new_client_with_http($stack->client); + + $id = $client->subscribeConfiguration('configstore', ['orderId1']); + + $request = $this->get_last_request($stack); + $this->assertRequestMethod('GET', $request); + $this->assertRequestUri('/v1.0/configuration/configstore/subscribe', $request); + $this->assertRequestQueryString('key=orderId1', $request); + $this->assertSame('sub-id', $id); + } + + public function testUnsubscribeConfiguration(): void + { + $stack = $this->get_http_client_stack([new Response(200, body: json_encode(['ok' => true]))]); + $client = $this->get_new_client_with_http($stack->client); + + $ok = $client->unsubscribeConfiguration('configstore', 'sub-id'); + + $request = $this->get_last_request($stack); + $this->assertRequestMethod('GET', $request); + $this->assertRequestUri('/v1.0/configuration/configstore/sub-id/unsubscribe', $request); + $this->assertTrue($ok); + } + + public function testUnsubscribeConfigurationFailure(): void + { + $stack = $this->get_http_client_stack( + [new Response(200, body: json_encode(['ok' => false, 'message' => 'subscription not found']))] + ); + $client = $this->get_new_client_with_http($stack->client); + + $this->assertFalse($client->unsubscribeConfiguration('configstore', 'nope')); + } + + public function testConfigurationUpdateParse(): void + { + $update = ConfigurationUpdate::parse( + json_encode( + [ + 'id' => 'sub-id', + 'items' => [ + 'orderId1' => ['value' => '103', 'version' => 'v2', 'metadata' => null], + 'orderId2' => ['value' => '104'], + ], + ] + ) + ); + + $this->assertSame('sub-id', $update->id); + $this->assertCount(2, $update->items); + $this->assertInstanceOf(ConfigurationItem::class, $update->items['orderId1']); + $this->assertSame('103', $update->items['orderId1']->value); + $this->assertSame('v2', $update->items['orderId1']->version); + $this->assertSame('104', $update->items['orderId2']->value); + $this->assertSame('', $update->items['orderId2']->version); + $this->assertNull($update->items['orderId2']->metadata); + } +} From cc0d5afac1cca946af5336fdba84f41c0d90b1f0 Mon Sep 17 00:00:00 2001 From: "Mark A. Hershberger" Date: Sun, 20 Sep 2026 21:55:23 -0400 Subject: [PATCH 2/2] Add configuration page to the PHP SDK docs Signed-off-by: Mark A. Hershberger --- .../en/php-sdk-docs/php-configuration.md | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 daprdocs/content/en/php-sdk-docs/php-configuration.md diff --git a/daprdocs/content/en/php-sdk-docs/php-configuration.md b/daprdocs/content/en/php-sdk-docs/php-configuration.md new file mode 100644 index 0000000..c46883f --- /dev/null +++ b/daprdocs/content/en/php-sdk-docs/php-configuration.md @@ -0,0 +1,59 @@ +--- +type: docs +title: "Configuration with PHP" +linkTitle: "Configuration" +weight: 1000 +description: How to read configuration items and subscribe to configuration changes +no_list: true +--- + +The [Configuration API]({{% ref configuration-api-overview %}}) lets you read configuration items from a +configuration store and subscribe to changes. + +## Getting configuration items + +Create a client and read items by key. Omit the keys to retrieve all items in the store: + +```php +build(); + +$items = $client->getConfiguration(storeName: 'configstore', keys: ['orderId1', 'orderId2']); + +foreach ($items as $key => $item) { + // $item is a \Dapr\Configuration\ConfigurationItem + echo "$key = {$item->value} (version: {$item->version})"; +} +``` + +## Subscribing to changes + +Subscribing returns a subscription id. The sidecar pushes updates to your application's +`/configuration/` route: + +```php +$id = $client->subscribeConfiguration(storeName: 'configstore', keys: ['orderId1', 'orderId2']); + +// when you no longer need updates +$client->unsubscribeConfiguration(storeName: 'configstore', id: $id); +``` + +Receive the pushed updates in an app route, parsing the body with +`\Dapr\Configuration\ConfigurationUpdate`: + +```php +post('/configuration/configstore', function (#[\Dapr\Attributes\FromBody] string $body) { + $update = \Dapr\Configuration\ConfigurationUpdate::parse($body); + foreach ($update->items as $key => $item) { + echo "config changed: $key = {$item->value}"; + } +}); +$app->start(); +``` + +Every client method also has an `*Async` variant (for example, `getConfigurationAsync()`) that returns a +Guzzle promise.