From a140b8a9409eac3342f1fb99bbf19eeb9e6d3f40 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 08:39:31 -0500 Subject: [PATCH 01/18] Add filter for cache-invalidating request headers --- docs/extending/hooks.md | 11 ++++ inc/HttpClient/RdbCacheStrategy.php | 18 +++++-- tests/inc/HttpClient/HttpClientTest.php | 60 ++++++++++++++++++++++ tests/inc/Mocks/MockWordPressFunctions.php | 7 ++- 4 files changed, 92 insertions(+), 4 deletions(-) diff --git a/docs/extending/hooks.md b/docs/extending/hooks.md index 9676decc7..fca795571 100644 --- a/docs/extending/hooks.md +++ b/docs/extending/hooks.md @@ -72,6 +72,17 @@ function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterf add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_schemes', 10, 2 ); ``` +### remote_data_blocks_cache_invalidating_request_headers + +Filter the request headers included in the object cache key. `Authorization` and `Cache-Control` are included by default. Add any custom authentication or response-varying headers to prevent responses for different header values from sharing a cache entry. The complete headers for the current request are provided as the second argument. + +```php +function custom_cache_invalidating_request_headers( array $cache_invalidating_request_headers, array $request_headers ): array { + return array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ); +} +add_filter( 'remote_data_blocks_cache_invalidating_request_headers', 'custom_cache_invalidating_request_headers', 10, 2 ); +``` + ### remote_data_blocks_pagination_query_var_name Filter the query variable name used for pagination (default: `rdb-pagination`). diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index e943d5a93..0cbec02b9 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -40,10 +40,22 @@ public static function get_object_cache_key_from_request( RequestInterface $requ $request_method = $request->getMethod(); $request_uri = (string) $request->getUri(); + /** + * Filters the request headers that are included in the object cache key. + * + * @param array $cache_invalidating_request_headers Header names included in the cache key. + * @param array $request_headers Headers from the current request. + */ + $cache_invalidating_request_headers = (array) apply_filters( + 'remote_data_blocks_cache_invalidating_request_headers', + self::CACHE_INVALIDATING_REQUEST_HEADERS, + $request_headers + ); + $cache_headers = []; - foreach ( self::CACHE_INVALIDATING_REQUEST_HEADERS as $header ) { - if ( isset( $request_headers[ $header ] ) ) { - $cache_headers[ $header ] = $request_headers[ $header ]; + foreach ( $cache_invalidating_request_headers as $header ) { + if ( $request->hasHeader( $header ) ) { + $cache_headers[ $header ] = $request->getHeader( $header ); } } diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index cb59b3dea..8f5dd7c98 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -11,6 +11,7 @@ use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use RemoteDataBlocks\HttpClient\HttpClient; +use RemoteDataBlocks\Tests\Mocks\MockWordPressFunctions; class HttpClientTest extends TestCase { private Client $client; @@ -30,6 +31,11 @@ protected function setUp(): void { $this->http_client = HttpClient::instance(); } + protected function tearDown(): void { + MockWordPressFunctions::reset(); + parent::tearDown(); + } + public function testSingleton(): void { $client = HttpClient::instance(); $this->assertInstanceOf( HttpClient::class, $client ); @@ -259,6 +265,60 @@ public function testRepeatedPostRequestsWithDifferentAuthorizationHeaderResultsI $this->assertEquals( 0, $this->mock_handler->count(), 'The mock handler should be empty after the second request' ); } + public function testFilteredCustomHeaderWithDifferentValuesResultsInCacheMiss(): void { + MockWordPressFunctions::add_mock_filter( + 'remote_data_blocks_cache_invalidating_request_headers', + function ( array $cache_invalidating_request_headers, array $request_headers ): array { + $this->assertSame( [ 'Authorization', 'Cache-Control' ], $cache_invalidating_request_headers ); + $this->assertArrayHasKey( 'X-Api-Key', $request_headers ); + + return array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ); + } + ); + + $this->mock_handler->append( + new Response( 200, [], 'First Response' ), + new Response( 200, [], 'Second Response' ) + ); + + $first_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'X-Api-Key' => 'first-api-key' ], + ], $this->client ); + $second_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'X-Api-Key' => 'second-api-key' ], + ], $this->client ); + + $this->assertSame( 'First Response', (string) $first_response->getBody() ); + $this->assertSame( RdbCacheMiddleware::HEADER_CACHE_MISS, $first_response->getHeaderLine( RdbCacheMiddleware::HEADER_CACHE_INFO ) ); + $this->assertSame( 'Second Response', (string) $second_response->getBody() ); + $this->assertSame( RdbCacheMiddleware::HEADER_CACHE_MISS, $second_response->getHeaderLine( RdbCacheMiddleware::HEADER_CACHE_INFO ) ); + $this->assertSame( 0, $this->mock_handler->count(), 'Both responses should be consumed when the custom header values differ' ); + } + + public function testFilteredCustomHeaderNameIsCaseInsensitive(): void { + MockWordPressFunctions::add_mock_filter( + 'remote_data_blocks_cache_invalidating_request_headers', + fn( array $cache_invalidating_request_headers ): array => array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ) + ); + + $this->mock_handler->append( + new Response( 200, [], 'First Response' ), + new Response( 200, [], 'Second Response' ) + ); + + $first_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'x-api-key' => 'first-api-key' ], + ], $this->client ); + $second_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'x-api-key' => 'second-api-key' ], + ], $this->client ); + + $this->assertSame( 'First Response', (string) $first_response->getBody() ); + $this->assertSame( 'Second Response', (string) $second_response->getBody() ); + $this->assertSame( RdbCacheMiddleware::HEADER_CACHE_MISS, $second_response->getHeaderLine( RdbCacheMiddleware::HEADER_CACHE_INFO ) ); + $this->assertSame( 0, $this->mock_handler->count(), 'Both responses should be consumed regardless of custom header casing' ); + } + public function testRepeatedPostRequestsWithDifferentBodyResultsInCacheMiss(): void { // Set up the mock handler with two responses $this->mock_handler->append( diff --git a/tests/inc/Mocks/MockWordPressFunctions.php b/tests/inc/Mocks/MockWordPressFunctions.php index 5b69c0d9e..415e41f39 100644 --- a/tests/inc/Mocks/MockWordPressFunctions.php +++ b/tests/inc/Mocks/MockWordPressFunctions.php @@ -21,7 +21,12 @@ class MockWordPressFunctions { public static function apply_filters( string $filter, mixed $thing, mixed ...$args ): mixed { self::$done_filters[ $filter ] = $args; - return self::$mocked_filters[ $filter ] ?? $thing; + $mocked_filter = self::$mocked_filters[ $filter ] ?? null; + if ( is_callable( $mocked_filter ) ) { + return $mocked_filter( $thing, ...$args ); + } + + return $mocked_filter ?? $thing; } public static function do_action( string $action, mixed ...$args ): void { From bf3dfe40ff38b54663ef4e07853fa08ac85c46a3 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 08:48:34 -0500 Subject: [PATCH 02/18] Document cache header security implications --- docs/concepts/index.md | 8 ++++++++ docs/extending/data-source.md | 4 ++++ docs/extending/hooks.md | 10 +++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 1a4ff2259..e905b55ed 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -16,6 +16,14 @@ This plugin offers a caching layer for optimal performance. It will be used if y The default TTL for all cache objects is 5 minutes, but it can be [configured per query or request](../extending/query.md#cache_ttl-intnullcallable). Error responses are cached for 30 seconds to avoid overwhelming the remote data source under error conditions. Multiple requests for the same data within a single page load will be deduplicated even if the requests are not cacheable. +### Cache isolation and custom request headers + +The response cache is shared across queries. When the site uses a persistent object cache, it is also shared across requests and users. Cache entries distinguish requests by their method, URI, body, and a configured list of request headers. `Authorization` and `Cache-Control` are included in that list by default, but arbitrary request headers are not included automatically. + +**Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data. + +Use the [`remote_data_blocks_cache_invalidating_request_headers`](../extending/hooks.md#remote_data_blocks_cache_invalidating_request_headers) filter to add every custom header that can affect the authorized or returned data. + ## Technical concepts If you want to understand the internals of Remote Data Blocks so that you can write code to extend its functionality, head over to the [extending guide](../extending/index.md). diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index 8bed478ad..c8d281d58 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -72,6 +72,10 @@ An associative array of headers that will be sent with each HTTP request. Querie When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. +**Security warning:** Custom authentication and response-varying headers are not included in the object cache key automatically. If two requests have the same method, URI, and body but use different values for an omitted header, one request can receive the response cached for the other. With a persistent object cache, this can expose data authorized for a different API credential or security context. + +When using a custom header such as `X-Api-Key`, add it with the [`remote_data_blocks_cache_invalidating_request_headers`](hooks.md#remote_data_blocks_cache_invalidating_request_headers) filter. Add every header that can affect authentication, authorization, tenancy, or the returned data. + ### Next steps After defining a data source in code, you can use it in a [query](query.md) to define how data is retrieved. diff --git a/docs/extending/hooks.md b/docs/extending/hooks.md index fca795571..5cb091b62 100644 --- a/docs/extending/hooks.md +++ b/docs/extending/hooks.md @@ -74,7 +74,15 @@ add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_scheme ### remote_data_blocks_cache_invalidating_request_headers -Filter the request headers included in the object cache key. `Authorization` and `Cache-Control` are included by default. Add any custom authentication or response-varying headers to prevent responses for different header values from sharing a cache entry. The complete headers for the current request are provided as the second argument. +Filter the request headers included in the object cache key. `Authorization` and `Cache-Control` are included by default. The plugin cannot determine whether an arbitrary header carries credentials or changes the response, so other headers must be added explicitly. The complete headers for the current request are provided as the second argument. + +#### Security implications + +The object cache is shared across queries. When the site uses a persistent object cache, it is also shared across requests and users. If two requests have the same method, URI, and body, the configured cache-invalidating headers are what prevent responses for different credentials or security contexts from sharing a cache entry. + +**Security warning:** When an API uses a custom header for authentication, authorization, tenancy, or any other response-varying value, omitting that header from this filter can cause one request to receive a response cached for another. With a persistent object cache, this can expose protected remote data across requests and users. + +Add every custom header that can affect the authorized or returned data. Always merge additions with the provided list so that the default `Authorization` and `Cache-Control` protections remain in place. Header names are matched case-insensitively, and it is safe to add a header that is absent from some requests because absent headers are ignored when generating the key. ```php function custom_cache_invalidating_request_headers( array $cache_invalidating_request_headers, array $request_headers ): array { From a205f96f59d3076cedf26bffe33cd087f5bd2cb0 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 09:02:47 -0500 Subject: [PATCH 03/18] Rename cache key request headers filter --- docs/concepts/index.md | 2 +- docs/extending/data-source.md | 2 +- docs/extending/hooks.md | 8 ++++---- inc/HttpClient/RdbCacheStrategy.php | 16 ++++++++-------- tests/inc/HttpClient/HttpClientTest.php | 12 ++++++------ 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/concepts/index.md b/docs/concepts/index.md index e905b55ed..f8f703b9b 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -22,7 +22,7 @@ The response cache is shared across queries. When the site uses a persistent obj **Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data. -Use the [`remote_data_blocks_cache_invalidating_request_headers`](../extending/hooks.md#remote_data_blocks_cache_invalidating_request_headers) filter to add every custom header that can affect the authorized or returned data. +Use the [`remote_data_blocks_cache_key_request_headers`](../extending/hooks.md#remote_data_blocks_cache_key_request_headers) filter to add every custom header that can affect the authorized or returned data. ## Technical concepts diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index c8d281d58..584d352b7 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -74,7 +74,7 @@ When providing authentication credentials, take care to avoid committing them to **Security warning:** Custom authentication and response-varying headers are not included in the object cache key automatically. If two requests have the same method, URI, and body but use different values for an omitted header, one request can receive the response cached for the other. With a persistent object cache, this can expose data authorized for a different API credential or security context. -When using a custom header such as `X-Api-Key`, add it with the [`remote_data_blocks_cache_invalidating_request_headers`](hooks.md#remote_data_blocks_cache_invalidating_request_headers) filter. Add every header that can affect authentication, authorization, tenancy, or the returned data. +When using a custom header such as `X-Api-Key`, add it with the [`remote_data_blocks_cache_key_request_headers`](hooks.md#remote_data_blocks_cache_key_request_headers) filter. Add every header that can affect authentication, authorization, tenancy, or the returned data. ### Next steps diff --git a/docs/extending/hooks.md b/docs/extending/hooks.md index 5cb091b62..5e31baa6d 100644 --- a/docs/extending/hooks.md +++ b/docs/extending/hooks.md @@ -72,7 +72,7 @@ function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterf add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_schemes', 10, 2 ); ``` -### remote_data_blocks_cache_invalidating_request_headers +### remote_data_blocks_cache_key_request_headers Filter the request headers included in the object cache key. `Authorization` and `Cache-Control` are included by default. The plugin cannot determine whether an arbitrary header carries credentials or changes the response, so other headers must be added explicitly. The complete headers for the current request are provided as the second argument. @@ -85,10 +85,10 @@ The object cache is shared across queries. When the site uses a persistent objec Add every custom header that can affect the authorized or returned data. Always merge additions with the provided list so that the default `Authorization` and `Cache-Control` protections remain in place. Header names are matched case-insensitively, and it is safe to add a header that is absent from some requests because absent headers are ignored when generating the key. ```php -function custom_cache_invalidating_request_headers( array $cache_invalidating_request_headers, array $request_headers ): array { - return array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ); +function custom_cache_key_request_headers( array $cache_key_request_headers, array $request_headers ): array { + return array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ); } -add_filter( 'remote_data_blocks_cache_invalidating_request_headers', 'custom_cache_invalidating_request_headers', 10, 2 ); +add_filter( 'remote_data_blocks_cache_key_request_headers', 'custom_cache_key_request_headers', 10, 2 ); ``` ### remote_data_blocks_pagination_query_var_name diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index 0cbec02b9..58d03f6c7 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -19,13 +19,13 @@ class RdbCacheStrategy extends GreedyCacheStrategy { public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL; public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks'; - private const CACHE_INVALIDATING_REQUEST_HEADERS = [ 'Authorization', 'Cache-Control' ]; + private const CACHE_KEY_REQUEST_HEADERS = [ 'Authorization', 'Cache-Control' ]; private const ERROR_CACHE_TTL_IN_SECONDS = 30; // 30 seconds for error responses private const FALLBACK_CACHE_TTL_IN_SECONDS = 300; // 5 minutes for success responses public function __construct( ?CacheStorageInterface $storage = null ) { // Filter this if customization is needed. - $vary_headers = new KeyValueHttpHeader( self::CACHE_INVALIDATING_REQUEST_HEADERS ); + $vary_headers = new KeyValueHttpHeader( self::CACHE_KEY_REQUEST_HEADERS ); parent::__construct( $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ), @@ -43,17 +43,17 @@ public static function get_object_cache_key_from_request( RequestInterface $requ /** * Filters the request headers that are included in the object cache key. * - * @param array $cache_invalidating_request_headers Header names included in the cache key. - * @param array $request_headers Headers from the current request. + * @param array $cache_key_request_headers Header names included in the cache key. + * @param array $request_headers Headers from the current request. */ - $cache_invalidating_request_headers = (array) apply_filters( - 'remote_data_blocks_cache_invalidating_request_headers', - self::CACHE_INVALIDATING_REQUEST_HEADERS, + $cache_key_request_headers = (array) apply_filters( + 'remote_data_blocks_cache_key_request_headers', + self::CACHE_KEY_REQUEST_HEADERS, $request_headers ); $cache_headers = []; - foreach ( $cache_invalidating_request_headers as $header ) { + foreach ( $cache_key_request_headers as $header ) { if ( $request->hasHeader( $header ) ) { $cache_headers[ $header ] = $request->getHeader( $header ); } diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index 8f5dd7c98..2e9e701f1 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -267,12 +267,12 @@ public function testRepeatedPostRequestsWithDifferentAuthorizationHeaderResultsI public function testFilteredCustomHeaderWithDifferentValuesResultsInCacheMiss(): void { MockWordPressFunctions::add_mock_filter( - 'remote_data_blocks_cache_invalidating_request_headers', - function ( array $cache_invalidating_request_headers, array $request_headers ): array { - $this->assertSame( [ 'Authorization', 'Cache-Control' ], $cache_invalidating_request_headers ); + 'remote_data_blocks_cache_key_request_headers', + function ( array $cache_key_request_headers, array $request_headers ): array { + $this->assertSame( [ 'Authorization', 'Cache-Control' ], $cache_key_request_headers ); $this->assertArrayHasKey( 'X-Api-Key', $request_headers ); - return array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ); + return array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ); } ); @@ -297,8 +297,8 @@ function ( array $cache_invalidating_request_headers, array $request_headers ): public function testFilteredCustomHeaderNameIsCaseInsensitive(): void { MockWordPressFunctions::add_mock_filter( - 'remote_data_blocks_cache_invalidating_request_headers', - fn( array $cache_invalidating_request_headers ): array => array_merge( $cache_invalidating_request_headers, [ 'X-Api-Key' ] ) + 'remote_data_blocks_cache_key_request_headers', + fn( array $cache_key_request_headers ): array => array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ) ); $this->mock_handler->append( From 40c6544616412ca41366897de4ea6b0fa4dfdcbc Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 11:24:47 -0500 Subject: [PATCH 04/18] Configure cache key headers per data source and query --- docs/concepts/index.md | 2 +- docs/extending/data-source.md | 13 +++- docs/extending/hooks.md | 19 ----- docs/extending/query.md | 10 +++ .../CacheKeyRequestHeadersInterface.php | 12 ++++ inc/Config/DataSource/HttpDataSource.php | 7 +- inc/Config/Query/HttpQuery.php | 20 +++++- inc/Config/QueryRunner/QueryRunner.php | 5 ++ inc/HttpClient/CacheKeyRequestHeaders.php | 32 +++++++++ inc/HttpClient/RdbCacheMiddleware.php | 24 +++++++ inc/HttpClient/RdbCacheStrategy.php | 18 ++--- .../GenericHttp/GenericHttpDataSource.php | 10 +++ .../Shopify/ShopifyDataSource.php | 1 + inc/Validation/ConfigSchemas.php | 2 + src/data-sources/http/HttpSettings.tsx | 21 ++++++ src/data-sources/types.ts | 1 + tests/inc/Config/HttpDataSourceTest.php | 12 ++++ tests/inc/Config/QueryRunnerTest.php | 25 +++++++ tests/inc/Config/QueryTest.php | 22 ++++++ tests/inc/HttpClient/HttpClientTest.php | 49 +++++++------ .../GenericHttp/GenericHttpDataSourceTest.php | 21 ++++++ .../Shopify/ShopifyDataSourceTest.php | 26 +++++++ tests/inc/Mocks/MockWordPressFunctions.php | 7 +- .../data-sources/http/HttpSettings.test.tsx | 72 +++++++++++++++++++ 24 files changed, 364 insertions(+), 67 deletions(-) create mode 100644 inc/Config/CacheKeyRequestHeadersInterface.php create mode 100644 inc/HttpClient/CacheKeyRequestHeaders.php create mode 100644 tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php create mode 100644 tests/src/data-sources/http/HttpSettings.test.tsx diff --git a/docs/concepts/index.md b/docs/concepts/index.md index f8f703b9b..5fe3a810b 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -22,7 +22,7 @@ The response cache is shared across queries. When the site uses a persistent obj **Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data. -Use the [`remote_data_blocks_cache_key_request_headers`](../extending/hooks.md#remote_data_blocks_cache_key_request_headers) filter to add every custom header that can affect the authorized or returned data. +Use the data source or query [`cache_key_request_headers`](../extending/data-source.md#cache_key_request_headers-array) configuration to add every custom header that can affect the authorized or returned data. Data-source entries apply to every query that uses that source; query entries are merged for one query. The built-in defaults cannot be removed. ## Technical concepts diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index 584d352b7..ae1679f2a 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -43,6 +43,7 @@ $data_source = [ 'Content-Type' => 'application/json', 'X-Api-Key' => constant( 'MY_API_KEY_CONSTANT' ), ], + 'cache_key_request_headers' => [ 'X-Api-Key' ], ]; ``` @@ -72,9 +73,17 @@ An associative array of headers that will be sent with each HTTP request. Querie When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. -**Security warning:** Custom authentication and response-varying headers are not included in the object cache key automatically. If two requests have the same method, URI, and body but use different values for an omitted header, one request can receive the response cached for the other. With a persistent object cache, this can expose data authorized for a different API credential or security context. +### cache_key_request_headers: array -When using a custom header such as `X-Api-Key`, add it with the [`remote_data_blocks_cache_key_request_headers`](hooks.md#remote_data_blocks_cache_key_request_headers) filter. Add every header that can affect authentication, authorization, tenancy, or the returned data. +A static list of request header names whose values will be included in object cache keys for every query that uses this data source. `Authorization` and `Cache-Control` are always included by default. Query-level entries are added to the data-source list, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. + +```php +'cache_key_request_headers' => [ 'X-Api-Key', 'X-Tenant-ID' ], +``` + +**Security warning:** Add every custom header that can affect authentication, authorization, tenancy, or the returned data. If two requests have the same method, URI, and body but use different values for an omitted header, one request can receive the response cached for the other. With a persistent object cache, this can expose protected data across requests and users. + +For Generic HTTP data sources configured in the plugin settings, custom API-key authentication headers are added automatically. Use the **Additional cache key headers** field for other authentication, tenancy, or response-varying headers. Shopify's storefront access-token header is also included automatically. ### Next steps diff --git a/docs/extending/hooks.md b/docs/extending/hooks.md index 5e31baa6d..9676decc7 100644 --- a/docs/extending/hooks.md +++ b/docs/extending/hooks.md @@ -72,25 +72,6 @@ function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterf add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_schemes', 10, 2 ); ``` -### remote_data_blocks_cache_key_request_headers - -Filter the request headers included in the object cache key. `Authorization` and `Cache-Control` are included by default. The plugin cannot determine whether an arbitrary header carries credentials or changes the response, so other headers must be added explicitly. The complete headers for the current request are provided as the second argument. - -#### Security implications - -The object cache is shared across queries. When the site uses a persistent object cache, it is also shared across requests and users. If two requests have the same method, URI, and body, the configured cache-invalidating headers are what prevent responses for different credentials or security contexts from sharing a cache entry. - -**Security warning:** When an API uses a custom header for authentication, authorization, tenancy, or any other response-varying value, omitting that header from this filter can cause one request to receive a response cached for another. With a persistent object cache, this can expose protected remote data across requests and users. - -Add every custom header that can affect the authorized or returned data. Always merge additions with the provided list so that the default `Authorization` and `Cache-Control` protections remain in place. Header names are matched case-insensitively, and it is safe to add a header that is absent from some requests because absent headers are ignored when generating the key. - -```php -function custom_cache_key_request_headers( array $cache_key_request_headers, array $request_headers ): array { - return array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ); -} -add_filter( 'remote_data_blocks_cache_key_request_headers', 'custom_cache_key_request_headers', 10, 2 ); -``` - ### remote_data_blocks_pagination_query_var_name Filter the query variable name used for pagination (default: `rdb-pagination`). diff --git a/docs/extending/query.md b/docs/extending/query.md index 9b215b091..86f60000b 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -139,6 +139,16 @@ The `request_headers` property defines the request headers for the query. It can }, ``` +### cache_key_request_headers: array + +A static list of additional request header names whose values will be included in the object cache key for this query. `Authorization` and `Cache-Control` are always included by default. Query entries are added to the list configured by the data source, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. + +```php +'cache_key_request_headers' => [ 'X-Request-Scope' ], +``` + +**Security warning:** Add every query-specific header that can affect authentication, authorization, tenancy, or the returned data. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. Prefer the [data-source configuration](data-source.md#cache_key_request_headers-array) when a header applies to every query for that source. + ### request_body: array|callable The `request_body` property defines the request body for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will not have a request body. diff --git a/inc/Config/CacheKeyRequestHeadersInterface.php b/inc/Config/CacheKeyRequestHeadersInterface.php new file mode 100644 index 000000000..fce87af80 --- /dev/null +++ b/inc/Config/CacheKeyRequestHeadersInterface.php @@ -0,0 +1,12 @@ + Request header names included in cache keys. + */ + public function get_cache_key_request_headers(): array; +} diff --git a/inc/Config/DataSource/HttpDataSource.php b/inc/Config/DataSource/HttpDataSource.php index a851e3047..0b88d69b5 100644 --- a/inc/Config/DataSource/HttpDataSource.php +++ b/inc/Config/DataSource/HttpDataSource.php @@ -3,6 +3,7 @@ namespace RemoteDataBlocks\Config\DataSource; use RemoteDataBlocks\Config\ArraySerializable; +use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Validation\ConfigSchemas; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; use WP_Error; @@ -12,7 +13,11 @@ * * Implements the HttpDataSourceInterface to define a generic HTTP data source. */ -class HttpDataSource extends ArraySerializable implements HttpDataSourceInterface { +class HttpDataSource extends ArraySerializable implements HttpDataSourceInterface, CacheKeyRequestHeadersInterface { + public function get_cache_key_request_headers(): array { + return $this->config['cache_key_request_headers'] ?? []; + } + final public function get_display_name(): string { return $this->config['display_name']; } diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index d63f4c7bb..09a90cc5f 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -3,9 +3,11 @@ namespace RemoteDataBlocks\Config\Query; use RemoteDataBlocks\Config\ArraySerializable; +use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Config\DataSource\HttpDataSourceInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; +use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\Validation\ConfigSchemas; use WP_Error; @@ -16,7 +18,7 @@ * * This class can be used to implement most HTTP queries. */ -class HttpQuery extends ArraySerializable implements HttpQueryInterface { +class HttpQuery extends ArraySerializable implements HttpQueryInterface, CacheKeyRequestHeadersInterface { /** * Execute the query with the provided input variables. Execution can be * customized by providing a custom query runner. @@ -38,6 +40,22 @@ public function execute_batch( array $array_of_input_variables ): array|WP_Error return $query_runner->execute_batch( $this, $array_of_input_variables ); } + /** + * Get the request header names whose values should be included in cache keys. + * + * @return array Request header names included in cache keys. + */ + public function get_cache_key_request_headers(): array { + $data_source = $this->get_data_source(); + $data_source_headers = $data_source instanceof CacheKeyRequestHeadersInterface ? $data_source->get_cache_key_request_headers() : []; + + return CacheKeyRequestHeaders::merge( + CacheKeyRequestHeaders::DEFAULT_HEADERS, + $data_source_headers, + $this->config['cache_key_request_headers'] ?? [] + ); + } + /** * Define the cache object TTL for the current query execution's responses: * - Return a positive integer to set a custom TTL in seconds. diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 2058cacd1..2567901d4 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -4,9 +4,12 @@ use Exception; use GuzzleHttp\RequestOptions; +use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Editor\DataBinding\Pagination; use RemoteDataBlocks\HttpClient\HttpClient; +use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; +use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use WP_Error; @@ -57,6 +60,7 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $body = $query->get_request_body( $input_variables ); $endpoint = $query->get_endpoint( $input_variables ); $cache_ttl = $query->get_cache_ttl( $input_variables ); + $cache_key_request_headers = $query instanceof CacheKeyRequestHeadersInterface ? $query->get_cache_key_request_headers() : CacheKeyRequestHeaders::DEFAULT_HEADERS; $parsed_url = wp_parse_url( $endpoint ); if ( false === $parsed_url ) { @@ -102,6 +106,7 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $request_details = [ 'method' => $method, 'options' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => $cache_key_request_headers, RequestOptions::HEADERS => array_merge( $headers, $cache_headers ), RequestOptions::JSON => $body, ], diff --git a/inc/HttpClient/CacheKeyRequestHeaders.php b/inc/HttpClient/CacheKeyRequestHeaders.php new file mode 100644 index 000000000..233514790 --- /dev/null +++ b/inc/HttpClient/CacheKeyRequestHeaders.php @@ -0,0 +1,32 @@ + ...$header_lists Request header name lists. + * @return array Merged request header names. + */ + public static function merge( array ...$header_lists ): array { + $merged_headers = []; + $seen_headers = []; + + foreach ( $header_lists as $header_list ) { + foreach ( $header_list as $header ) { + $normalized_header = strtolower( $header ); + if ( isset( $seen_headers[ $normalized_header ] ) ) { + continue; + } + + $seen_headers[ $normalized_header ] = true; + $merged_headers[] = $header; + } + } + + return $merged_headers; + } +} diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index 88411f4bd..c974dd34a 100644 --- a/inc/HttpClient/RdbCacheMiddleware.php +++ b/inc/HttpClient/RdbCacheMiddleware.php @@ -2,7 +2,31 @@ namespace RemoteDataBlocks\HttpClient; +use Psr\Http\Message\RequestInterface; + class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware { + public const CACHE_KEY_REQUEST_HEADERS_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; + public const CACHE_KEY_REQUEST_HEADERS_OPTION = 'remote_data_blocks_cache_key_request_headers'; + + public function __invoke( callable $handler ): callable { + $handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) { + return $handler( $request->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_HEADER ), $options ); + }; + $cache_handler = parent::__invoke( $handler_without_cache_metadata ); + + return function ( RequestInterface $request, array $options ) use ( $cache_handler ) { + $cache_key_request_headers = $options[ self::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? []; + unset( $options[ self::CACHE_KEY_REQUEST_HEADERS_OPTION ] ); + + if ( is_array( $cache_key_request_headers ) ) { + $cache_key_request_headers = array_values( array_filter( $cache_key_request_headers, 'is_string' ) ); + $request = $request->withHeader( self::CACHE_KEY_REQUEST_HEADERS_HEADER, $cache_key_request_headers ); + } + + return $cache_handler( $request, $options ); + }; + } + /** * @var array */ diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index 58d03f6c7..4dd6c8908 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -19,13 +19,11 @@ class RdbCacheStrategy extends GreedyCacheStrategy { public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL; public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks'; - private const CACHE_KEY_REQUEST_HEADERS = [ 'Authorization', 'Cache-Control' ]; private const ERROR_CACHE_TTL_IN_SECONDS = 30; // 30 seconds for error responses private const FALLBACK_CACHE_TTL_IN_SECONDS = 300; // 5 minutes for success responses public function __construct( ?CacheStorageInterface $storage = null ) { - // Filter this if customization is needed. - $vary_headers = new KeyValueHttpHeader( self::CACHE_KEY_REQUEST_HEADERS ); + $vary_headers = new KeyValueHttpHeader( CacheKeyRequestHeaders::DEFAULT_HEADERS ); parent::__construct( $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ), @@ -36,20 +34,12 @@ public function __construct( ?CacheStorageInterface $storage = null ) { public static function get_object_cache_key_from_request( RequestInterface $request ): string { $request_body = (string) $request->getBody(); - $request_headers = $request->getHeaders(); $request_method = $request->getMethod(); $request_uri = (string) $request->getUri(); - /** - * Filters the request headers that are included in the object cache key. - * - * @param array $cache_key_request_headers Header names included in the cache key. - * @param array $request_headers Headers from the current request. - */ - $cache_key_request_headers = (array) apply_filters( - 'remote_data_blocks_cache_key_request_headers', - self::CACHE_KEY_REQUEST_HEADERS, - $request_headers + $cache_key_request_headers = CacheKeyRequestHeaders::merge( + CacheKeyRequestHeaders::DEFAULT_HEADERS, + $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); $cache_headers = []; diff --git a/inc/Integrations/GenericHttp/GenericHttpDataSource.php b/inc/Integrations/GenericHttp/GenericHttpDataSource.php index 87f2d34a6..257469b6a 100644 --- a/inc/Integrations/GenericHttp/GenericHttpDataSource.php +++ b/inc/Integrations/GenericHttp/GenericHttpDataSource.php @@ -3,6 +3,7 @@ namespace RemoteDataBlocks\Integrations\GenericHttp; use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\Validation\Types; use RemoteDataBlocks\Validation\Validator; use RemoteDataBlocks\Validation\ConfigSchemas; @@ -23,6 +24,7 @@ protected static function get_service_config_schema(): array { 'value' => Types::skip_sanitize( Types::string() ), ] ) ), + 'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ), 'display_name' => Types::string(), 'endpoint' => Types::string(), ] ); @@ -64,12 +66,20 @@ public static function get_request_headers_from_service_config( array $service_c return []; } + public static function get_cache_key_request_headers_from_service_config( array $service_config ): array { + return CacheKeyRequestHeaders::merge( + array_keys( self::get_request_headers_from_service_config( $service_config ) ), + $service_config['cache_key_request_headers'] ?? [] + ); + } + final public function get_service_name(): string { return static::SERVICE_NAME; } protected static function map_service_config( array $service_config ): array { return [ + 'cache_key_request_headers' => self::get_cache_key_request_headers_from_service_config( $service_config ), 'display_name' => $service_config['display_name'], 'endpoint' => self::get_endpoint_from_service_config( $service_config ), 'request_headers' => self::get_request_headers_from_service_config( $service_config ), diff --git a/inc/Integrations/Shopify/ShopifyDataSource.php b/inc/Integrations/Shopify/ShopifyDataSource.php index 5f1eae362..c79b1a718 100644 --- a/inc/Integrations/Shopify/ShopifyDataSource.php +++ b/inc/Integrations/Shopify/ShopifyDataSource.php @@ -35,6 +35,7 @@ protected static function map_service_config( array $service_config ): array { } return [ + 'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ], 'display_name' => $service_config['display_name'], 'endpoint' => $endpoint, 'image_url' => plugins_url( './assets/shopify_logo_black.png', __FILE__ ), diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php index 0aedb9d8f..782b4229f 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -133,6 +133,7 @@ private static function generate_graphql_query_config_schema(): array { private static function generate_http_data_source_config_schema(): array { return Types::object( [ + 'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ), 'display_name' => Types::string(), 'endpoint' => Types::string(), 'image_url' => Types::nullable( Types::image_url() ), @@ -159,6 +160,7 @@ private static function generate_http_query_config_schema(): array { return Types::object( [ 'display_name' => Types::nullable( Types::string() ), 'cache_ttl' => Types::nullable( Types::one_of( Types::callable(), Types::integer(), Types::null() ) ), + 'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ), 'data_source' => Types::one_of( Types::instance_of( HttpDataSource::class ), Types::serialized_config_for( HttpDataSource::class ), diff --git a/src/data-sources/http/HttpSettings.tsx b/src/data-sources/http/HttpSettings.tsx index ef7396ec4..f9d025913 100644 --- a/src/data-sources/http/HttpSettings.tsx +++ b/src/data-sources/http/HttpSettings.tsx @@ -2,6 +2,7 @@ import { Card, CardBody, ExternalLink, TextControl, Tip } from '@wordpress/compo import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; +import { CustomFormFieldToken } from '@/data-sources/components/CustomFormFieldToken'; import { HttpAuthSettingsInput } from '@/data-sources/components/HttpAuthSettingsInput'; import { ConfigSource } from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; @@ -93,6 +94,26 @@ export const HttpSettings = ( { mode, uuid, config }: SettingsComponentProps< Ht /> + ) => { + handleOnChange( + 'cache_key_request_headers', + headers.map( header => ( 'object' === typeof header ? header.value : header ) ) + ); + } } + suggestions={ [] } + __experimentalValidateInput={ ( input: string ) => + /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test( input ) + } + __nextHasNoMarginBottom + __next40pxDefaultSize + customHelpText={ __( + 'Custom authentication headers configured above are included automatically. Add every other header that can affect authentication, authorization, tenancy, or returned data. Omitting one can expose cached data across security contexts.', + 'remote-data-blocks' + ) } + /> diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts index 9f5124425..7b9ca3ca5 100644 --- a/src/data-sources/types.ts +++ b/src/data-sources/types.ts @@ -57,6 +57,7 @@ export interface GoogleSheetsServiceConfig extends BaseServiceConfig { export interface HttpServiceConfig extends BaseServiceConfig { auth?: HttpAuth; + cache_key_request_headers?: string[]; endpoint: string; } diff --git a/tests/inc/Config/HttpDataSourceTest.php b/tests/inc/Config/HttpDataSourceTest.php index 051b63a1e..2b71fabe8 100644 --- a/tests/inc/Config/HttpDataSourceTest.php +++ b/tests/inc/Config/HttpDataSourceTest.php @@ -3,10 +3,22 @@ namespace RemoteDataBlocks\Tests\Config; use PHPUnit\Framework\TestCase; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Tests\Mocks\MockDataSource; use WP_Error; class HttpDataSourceTest extends TestCase { + public function testCacheKeyRequestHeadersCanBeConfigured(): void { + $data_source = HttpDataSource::from_array( [ + 'display_name' => 'Custom API', + 'endpoint' => 'https://example.com/api', + 'cache_key_request_headers' => [ 'X-Api-Key', 'X-Tenant-ID' ], + ] ); + + $this->assertInstanceOf( HttpDataSource::class, $data_source ); + $this->assertSame( [ 'X-Api-Key', 'X-Tenant-ID' ], $data_source->get_cache_key_request_headers() ); + } + public function test_migrate_config_moves_header(): void { $config = [ 'display_name' => 'Mock Data Source', diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index ded8d4354..d6bde6343 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -5,6 +5,7 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\Tests\Mocks\MockDataSource; @@ -93,6 +94,30 @@ public function testExecuteSuccessfulRequest( string $endpoint ): void { $this->assertArrayHasKey( 'results', $result ); } + public function testRequestDetailsIncludeCacheKeyRequestHeadersOption(): void { + $data_source = MockDataSource::create( array_merge( + MockDataSource::MOCK_CONFIG, + [ 'cache_key_request_headers' => [ 'X-Api-Key' ] ] + ) ); + $this->assertInstanceOf( MockDataSource::class, $data_source ); + + $query = MockQuery::create( [ 'data_source' => $data_source ] ); + $this->assertInstanceOf( MockQuery::class, $query ); + + $query_runner = new class($this->http_client, []) extends QueryRunner { + public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error { + return $this->get_request_details( $query, [] ); + } + }; + + $request_details = $query_runner->get_request_details_for_test( $query ); + $this->assertIsArray( $request_details ); + $this->assertSame( + [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + $request_details['options']['remote_data_blocks_cache_key_request_headers'] ?? null + ); + } + public static function provideInvalidEndpoints(): array { return [ [ diff --git a/tests/inc/Config/QueryTest.php b/tests/inc/Config/QueryTest.php index 8cd69ea7d..52df4196e 100644 --- a/tests/inc/Config/QueryTest.php +++ b/tests/inc/Config/QueryTest.php @@ -3,6 +3,7 @@ namespace RemoteDataBlocks\Tests\Config; use PHPUnit\Framework\TestCase; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Tests\Mocks\MockDataSource; @@ -37,6 +38,27 @@ public function testGetRequestHeaders(): void { $this->assertSame( [ 'Content-Type' => 'application/json' ], $result ); } + public function testCacheKeyRequestHeadersMergeDefaultsDataSourceAndQuery(): void { + $data_source = HttpDataSource::from_array( [ + 'display_name' => 'Custom API', + 'endpoint' => 'https://example.com/api', + 'cache_key_request_headers' => [ 'X-Api-Key', 'x-tenant-id' ], + ] ); + $this->assertInstanceOf( HttpDataSource::class, $data_source ); + + $query = HttpQuery::from_array( [ + 'data_source' => $data_source, + 'output_schema' => [ 'type' => 'null' ], + 'cache_key_request_headers' => [ 'x-api-key', 'X-Request-Scope' ], + ] ); + $this->assertInstanceOf( HttpQuery::class, $query ); + + $this->assertSame( + [ 'Authorization', 'Cache-Control', 'X-Api-Key', 'x-tenant-id', 'X-Request-Scope' ], + $query->get_cache_key_request_headers() + ); + } + public function testGetRequestBody(): void { $this->assertNull( $this->query_context->get_request_body( [] ) ); } diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index 2e9e701f1..39131f3ba 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -6,12 +6,12 @@ use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; use GuzzleHttp\Psr7\Response; use Kevinrob\GuzzleCache\Storage\VolatileRuntimeStorage; use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use RemoteDataBlocks\HttpClient\HttpClient; -use RemoteDataBlocks\Tests\Mocks\MockWordPressFunctions; class HttpClientTest extends TestCase { private Client $client; @@ -31,11 +31,6 @@ protected function setUp(): void { $this->http_client = HttpClient::instance(); } - protected function tearDown(): void { - MockWordPressFunctions::reset(); - parent::tearDown(); - } - public function testSingleton(): void { $client = HttpClient::instance(); $this->assertInstanceOf( HttpClient::class, $client ); @@ -265,26 +260,18 @@ public function testRepeatedPostRequestsWithDifferentAuthorizationHeaderResultsI $this->assertEquals( 0, $this->mock_handler->count(), 'The mock handler should be empty after the second request' ); } - public function testFilteredCustomHeaderWithDifferentValuesResultsInCacheMiss(): void { - MockWordPressFunctions::add_mock_filter( - 'remote_data_blocks_cache_key_request_headers', - function ( array $cache_key_request_headers, array $request_headers ): array { - $this->assertSame( [ 'Authorization', 'Cache-Control' ], $cache_key_request_headers ); - $this->assertArrayHasKey( 'X-Api-Key', $request_headers ); - - return array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ); - } - ); - + public function testConfiguredCustomHeaderWithDifferentValuesResultsInCacheMiss(): void { $this->mock_handler->append( new Response( 200, [], 'First Response' ), new Response( 200, [], 'Second Response' ) ); $first_response = $this->http_client->request( 'GET', '/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], 'headers' => [ 'X-Api-Key' => 'first-api-key' ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], 'headers' => [ 'X-Api-Key' => 'second-api-key' ], ], $this->client ); @@ -295,21 +282,18 @@ function ( array $cache_key_request_headers, array $request_headers ): array { $this->assertSame( 0, $this->mock_handler->count(), 'Both responses should be consumed when the custom header values differ' ); } - public function testFilteredCustomHeaderNameIsCaseInsensitive(): void { - MockWordPressFunctions::add_mock_filter( - 'remote_data_blocks_cache_key_request_headers', - fn( array $cache_key_request_headers ): array => array_merge( $cache_key_request_headers, [ 'X-Api-Key' ] ) - ); - + public function testConfiguredCustomHeaderNameIsCaseInsensitive(): void { $this->mock_handler->append( new Response( 200, [], 'First Response' ), new Response( 200, [], 'Second Response' ) ); $first_response = $this->http_client->request( 'GET', '/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], 'headers' => [ 'x-api-key' => 'first-api-key' ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], 'headers' => [ 'x-api-key' => 'second-api-key' ], ], $this->client ); @@ -319,6 +303,25 @@ public function testFilteredCustomHeaderNameIsCaseInsensitive(): void { $this->assertSame( 0, $this->mock_handler->count(), 'Both responses should be consumed regardless of custom header casing' ); } + public function testCacheKeyRequestHeaderMetadataIsNotSentToRequestHandler(): void { + $transactions = []; + $mock_handler = new MockHandler( [ new Response( 200, [], 'Success' ) ] ); + $handler_stack = HandlerStack::create( $mock_handler ); + $handler_stack->push( new RdbCacheMiddleware( new RdbCacheStrategy( new VolatileRuntimeStorage() ) ), 'cache' ); + $handler_stack->push( Middleware::history( $transactions ), 'history' ); + $client = new Client( [ 'handler' => $handler_stack ] ); + + $this->http_client->request( 'GET', '/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], + 'headers' => [ 'X-Api-Key' => 'secret' ], + ], $client ); + + $this->assertCount( 1, $transactions ); + $this->assertSame( 'secret', $transactions[0]['request']->getHeaderLine( 'X-Api-Key' ) ); + $this->assertFalse( $transactions[0]['request']->hasHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); + $this->assertArrayNotHasKey( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION, $transactions[0]['options'] ); + } + public function testRepeatedPostRequestsWithDifferentBodyResultsInCacheMiss(): void { // Set up the mock handler with two responses $this->mock_handler->append( diff --git a/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php b/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php index c2510b7af..e3782a16f 100644 --- a/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php +++ b/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php @@ -4,6 +4,7 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Integrations\GenericHttp\GenericHttpDataSource; +use WP_Error; class GenericHttpDataSourceTest extends TestCase { @@ -37,4 +38,24 @@ public function test_to_array_returns_correctly_mapped_values(): void { $this->assertEquals( 'generic-http', $data_source_array['service'] ); $this->assertEquals( 'http://example.com', $data_source_array['service_config']['endpoint'] ); } + + public function testCacheKeyRequestHeadersIncludeApiKeyHeaderAndManualAdditions(): void { + $data_source = GenericHttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'auth' => [ + 'add_to' => 'header', + 'key' => 'X-Api-Key', + 'type' => 'api-key', + 'value' => 'secret', + ], + 'cache_key_request_headers' => [ 'X-Tenant-ID', 'x-api-key' ], + 'display_name' => 'Mock Data Source', + 'endpoint' => 'https://example.com', + ], + ] ); + + $this->assertNotInstanceOf( WP_Error::class, $data_source ); + $this->assertSame( [ 'X-Api-Key', 'X-Tenant-ID' ], $data_source->get_cache_key_request_headers() ); + } } diff --git a/tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php b/tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php new file mode 100644 index 000000000..3d82b6fd7 --- /dev/null +++ b/tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php @@ -0,0 +1,26 @@ + [ + '__version' => 1, + 'access_token' => 'secret', + 'display_name' => 'Shopify Store', + 'store_name' => 'example', + ], + ] ); + + $this->assertNotInstanceOf( WP_Error::class, $data_source ); + $this->assertSame( + [ 'X-Shopify-Storefront-Access-Token' ], + $data_source->get_cache_key_request_headers() + ); + } +} diff --git a/tests/inc/Mocks/MockWordPressFunctions.php b/tests/inc/Mocks/MockWordPressFunctions.php index 415e41f39..5b69c0d9e 100644 --- a/tests/inc/Mocks/MockWordPressFunctions.php +++ b/tests/inc/Mocks/MockWordPressFunctions.php @@ -21,12 +21,7 @@ class MockWordPressFunctions { public static function apply_filters( string $filter, mixed $thing, mixed ...$args ): mixed { self::$done_filters[ $filter ] = $args; - $mocked_filter = self::$mocked_filters[ $filter ] ?? null; - if ( is_callable( $mocked_filter ) ) { - return $mocked_filter( $thing, ...$args ); - } - - return $mocked_filter ?? $thing; + return self::$mocked_filters[ $filter ] ?? $thing; } public static function do_action( string $action, mixed ...$args ): void { diff --git a/tests/src/data-sources/http/HttpSettings.test.tsx b/tests/src/data-sources/http/HttpSettings.test.tsx new file mode 100644 index 000000000..ad50c8f4e --- /dev/null +++ b/tests/src/data-sources/http/HttpSettings.test.tsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { ConfigSource } from '@/data-sources/constants'; +import { HttpSettings } from '@/data-sources/http/HttpSettings'; +import { HttpConfig } from '@/data-sources/types'; +import { SettingsContext } from '@/settings/hooks/useSettingsNav'; + +const mocks = vi.hoisted( () => ( { + canUseDisplayName: vi.fn( () => true ), + onSave: vi.fn( async () => undefined ), +} ) ); + +vi.mock( '@/data-sources/hooks/useDataSources', () => ( { + useDataSources: () => ( { + canUseDisplayName: mocks.canUseDisplayName, + onSave: mocks.onSave, + } ), +} ) ); + +describe( 'HttpSettings', () => { + beforeEach( () => { + mocks.onSave.mockClear(); + document.body.innerHTML = '
'; + } ); + + it( 'saves additional cache key request headers', async () => { + const user = userEvent.setup(); + const config: HttpConfig = { + config_source: ConfigSource.STORAGE, + service: 'generic-http', + service_config: { + __version: 1, + auth: { type: 'none', value: '' }, + cache_key_request_headers: [], + display_name: 'Custom API', + enable_blocks: false, + endpoint: 'https://example.com', + }, + uuid: '00000000-0000-4000-8000-000000000000', + }; + + render( + + + + ); + + const headerInput = screen.getByRole( 'combobox', { + name: 'Additional cache key headers', + } ); + await user.type( headerInput, 'X-Tenant-ID{enter}' ); + await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); + + expect( mocks.onSave ).toHaveBeenCalledWith( + expect.objectContaining( { + service_config: expect.objectContaining( { + cache_key_request_headers: [ 'X-Tenant-ID' ], + } ), + } ), + 'edit' + ); + } ); +} ); From 29c7ecb83ad38a1abffb85206a1cdc919f3b43ed Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 11:34:24 -0500 Subject: [PATCH 05/18] Preserve cache headers for custom queries and logs --- inc/Config/QueryRunner/QueryRunner.php | 11 ++++- inc/HttpClient/RdbCacheStrategy.php | 7 ++- inc/HttpClient/RdbLogMiddleware.php | 6 ++- tests/inc/Config/QueryRunnerTest.php | 32 ++++++++++++++ tests/inc/HttpClient/RdbLogMiddlewareTest.php | 43 +++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 tests/inc/HttpClient/RdbLogMiddlewareTest.php diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 2567901d4..17a014318 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -7,8 +7,8 @@ use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Editor\DataBinding\Pagination; -use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; +use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use WP_Error; @@ -60,7 +60,14 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $body = $query->get_request_body( $input_variables ); $endpoint = $query->get_endpoint( $input_variables ); $cache_ttl = $query->get_cache_ttl( $input_variables ); - $cache_key_request_headers = $query instanceof CacheKeyRequestHeadersInterface ? $query->get_cache_key_request_headers() : CacheKeyRequestHeaders::DEFAULT_HEADERS; + $data_source = $query->get_data_source(); + $data_source_cache_key_request_headers = $data_source instanceof CacheKeyRequestHeadersInterface ? $data_source->get_cache_key_request_headers() : []; + $query_cache_key_request_headers = $query instanceof CacheKeyRequestHeadersInterface ? $query->get_cache_key_request_headers() : []; + $cache_key_request_headers = CacheKeyRequestHeaders::merge( + CacheKeyRequestHeaders::DEFAULT_HEADERS, + $data_source_cache_key_request_headers, + $query_cache_key_request_headers + ); $parsed_url = wp_parse_url( $endpoint ); if ( false === $parsed_url ) { diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index 4dd6c8908..191e20343 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -32,14 +32,17 @@ public function __construct( ?CacheStorageInterface $storage = null ) { ); } - public static function get_object_cache_key_from_request( RequestInterface $request ): string { + /** + * @param array|null $cache_key_request_headers Request headers included in the cache key. When omitted, read them from the request metadata header. + */ + public static function get_object_cache_key_from_request( RequestInterface $request, ?array $cache_key_request_headers = null ): string { $request_body = (string) $request->getBody(); $request_method = $request->getMethod(); $request_uri = (string) $request->getUri(); $cache_key_request_headers = CacheKeyRequestHeaders::merge( CacheKeyRequestHeaders::DEFAULT_HEADERS, - $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) + $cache_key_request_headers ?? $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); $cache_headers = []; diff --git a/inc/HttpClient/RdbLogMiddleware.php b/inc/HttpClient/RdbLogMiddleware.php index cf44274c8..593266e88 100644 --- a/inc/HttpClient/RdbLogMiddleware.php +++ b/inc/HttpClient/RdbLogMiddleware.php @@ -33,14 +33,16 @@ public function __invoke( callable $handler ): callable { }; } - private function log( RequestInterface $request, ?ResponseInterface $response, ?\Exception $reason ): void { + private function log( RequestInterface $request, ?ResponseInterface $response, ?\Exception $reason, array $options ): void { $response_headers = $response ? $response->getHeaders() : []; $uri = $request->getUri(); + $cache_key_request_headers = $options[ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? []; + $cache_key_request_headers = is_array( $cache_key_request_headers ) ? array_values( array_filter( $cache_key_request_headers, 'is_string' ) ) : []; $context = [ 'cache_age' => $response_headers[ RdbCacheStrategy::CACHE_AGE_RESPONSE_HEADER ][0] ?? '', 'cache_group' => RdbCacheStrategy::WP_OBJECT_CACHE_GROUP ?? '', - 'cache_key' => RdbCacheStrategy::get_object_cache_key_from_request( $request ), + 'cache_key' => RdbCacheStrategy::get_object_cache_key_from_request( $request, $cache_key_request_headers ), 'cache_status' => $response_headers[ CacheMiddleware::HEADER_CACHE_INFO ][0] ?? '', 'error' => $reason, 'hostname' => $uri->getHost(), diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index d6bde6343..40a23daf3 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -5,9 +5,11 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\HttpClient\HttpClient; +use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\Tests\Mocks\MockDataSource; use RemoteDataBlocks\Tests\Mocks\MockQuery; use WP_Error; @@ -118,6 +120,36 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array ); } + public function testRequestDetailsIncludeDataSourceHeadersForLegacyCustomQuery(): void { + $data_source = MockDataSource::create( array_merge( + MockDataSource::MOCK_CONFIG, + [ 'cache_key_request_headers' => [ 'X-Tenant-ID' ] ] + ) ); + $this->assertInstanceOf( MockDataSource::class, $data_source ); + + $query = $this->createMock( HttpQueryInterface::class ); + $this->assertNotInstanceOf( CacheKeyRequestHeadersInterface::class, $query ); + $query->method( 'get_data_source' )->willReturn( $data_source ); + $query->method( 'get_request_headers' )->willReturn( [] ); + $query->method( 'get_request_method' )->willReturn( 'GET' ); + $query->method( 'get_request_body' )->willReturn( null ); + $query->method( 'get_endpoint' )->willReturn( 'https://example.com/api' ); + $query->method( 'get_cache_ttl' )->willReturn( null ); + + $query_runner = new class($this->http_client, []) extends QueryRunner { + public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error { + return $this->get_request_details( $query, [] ); + } + }; + + $request_details = $query_runner->get_request_details_for_test( $query ); + $this->assertIsArray( $request_details ); + $this->assertSame( + [ 'Authorization', 'Cache-Control', 'X-Tenant-ID' ], + $request_details['options'][ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? null + ); + } + public static function provideInvalidEndpoints(): array { return [ [ diff --git a/tests/inc/HttpClient/RdbLogMiddlewareTest.php b/tests/inc/HttpClient/RdbLogMiddlewareTest.php new file mode 100644 index 000000000..856716bc4 --- /dev/null +++ b/tests/inc/HttpClient/RdbLogMiddlewareTest.php @@ -0,0 +1,43 @@ + [ 'X-Api-Key' ] ]; + $log_handler( + new Request( 'GET', 'https://example.com/data', [ 'X-Api-Key' => 'first-api-key' ] ), + $first_options + )->wait(); + + $second_options = [ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ] ]; + $log_handler( + new Request( 'GET', 'https://example.com/data', [ 'X-Api-Key' => 'second-api-key' ] ), + $second_options + )->wait(); + + $first_log = MockWordPressFunctions::get_done_action( RdbLogMiddleware::$action_name, 0 ); + $second_log = MockWordPressFunctions::get_done_action( RdbLogMiddleware::$action_name, 1 ); + $this->assertIsArray( $first_log ); + $this->assertIsArray( $second_log ); + $this->assertNotSame( $first_log[0]['cache_key'], $second_log[0]['cache_key'] ); + } +} From 6c8fd1073cd4f0ef36ea5e561cf9bbaa48deeebf Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 12:59:03 -0500 Subject: [PATCH 06/18] Consolidate cache key headers on queries --- docs/concepts/index.md | 2 +- docs/extending/data-source.md | 13 +--- docs/extending/query.md | 4 +- .../CacheKeyRequestHeadersInterface.php | 12 ---- inc/Config/DataSource/HttpDataSource.php | 7 +- inc/Config/Query/HttpQuery.php | 7 +- inc/Config/Query/HttpQueryInterface.php | 8 +++ inc/Config/QueryRunner/QueryRunner.php | 7 +- inc/HttpClient/RdbCacheStrategy.php | 9 +-- .../GenericHttp/GenericHttpDataSource.php | 10 --- .../Shopify/ShopifyDataSource.php | 1 - .../Shopify/ShopifyIntegration.php | 2 + inc/Validation/ConfigSchemas.php | 1 - src/data-sources/http/HttpSettings.tsx | 21 ------ src/data-sources/types.ts | 1 - tests/inc/Config/HttpDataSourceTest.php | 12 ---- tests/inc/Config/QueryRunnerTest.php | 41 ++--------- tests/inc/Config/QueryTest.php | 4 +- .../GenericHttp/GenericHttpDataSourceTest.php | 21 ------ ...rceTest.php => ShopifyIntegrationTest.php} | 17 +++-- tests/inc/Mocks/MockQuery.php | 1 + .../data-sources/http/HttpSettings.test.tsx | 72 ------------------- 22 files changed, 39 insertions(+), 234 deletions(-) delete mode 100644 inc/Config/CacheKeyRequestHeadersInterface.php rename tests/inc/Integrations/Shopify/{ShopifyDataSourceTest.php => ShopifyIntegrationTest.php} (52%) delete mode 100644 tests/src/data-sources/http/HttpSettings.test.tsx diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 5fe3a810b..c5267a2e6 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -22,7 +22,7 @@ The response cache is shared across queries. When the site uses a persistent obj **Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data. -Use the data source or query [`cache_key_request_headers`](../extending/data-source.md#cache_key_request_headers-array) configuration to add every custom header that can affect the authorized or returned data. Data-source entries apply to every query that uses that source; query entries are merged for one query. The built-in defaults cannot be removed. +Use each query's [`cache_key_request_headers`](../extending/query.md#cache_key_request_headers-array) configuration to add every custom header that can affect the authorized or returned data. Headers defined by a data source are not added to cache keys automatically. The built-in defaults cannot be removed. ## Technical concepts diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index ae1679f2a..c7b9fdbea 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -43,7 +43,6 @@ $data_source = [ 'Content-Type' => 'application/json', 'X-Api-Key' => constant( 'MY_API_KEY_CONSTANT' ), ], - 'cache_key_request_headers' => [ 'X-Api-Key' ], ]; ``` @@ -73,17 +72,7 @@ An associative array of headers that will be sent with each HTTP request. Querie When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. -### cache_key_request_headers: array - -A static list of request header names whose values will be included in object cache keys for every query that uses this data source. `Authorization` and `Cache-Control` are always included by default. Query-level entries are added to the data-source list, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. - -```php -'cache_key_request_headers' => [ 'X-Api-Key', 'X-Tenant-ID' ], -``` - -**Security warning:** Add every custom header that can affect authentication, authorization, tenancy, or the returned data. If two requests have the same method, URI, and body but use different values for an omitted header, one request can receive the response cached for the other. With a persistent object cache, this can expose protected data across requests and users. - -For Generic HTTP data sources configured in the plugin settings, custom API-key authentication headers are added automatically. Use the **Additional cache key headers** field for other authentication, tenancy, or response-varying headers. Shopify's storefront access-token header is also included automatically. +**Security warning:** Defining a custom authentication, authorization, tenancy, or response-varying header on a data source does not automatically include it in cache keys. Add the header name to the [`cache_key_request_headers`](query.md#cache_key_request_headers-array) configuration of every query that uses it. Otherwise, requests with different header values can share cached responses and potentially expose protected data across requests or users when a persistent object cache is enabled. ### Next steps diff --git a/docs/extending/query.md b/docs/extending/query.md index 86f60000b..90cc25089 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -141,13 +141,13 @@ The `request_headers` property defines the request headers for the query. It can ### cache_key_request_headers: array -A static list of additional request header names whose values will be included in the object cache key for this query. `Authorization` and `Cache-Control` are always included by default. Query entries are added to the list configured by the data source, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. +A static list of additional request header names whose values will be included in the object cache key for this query. `Authorization` and `Cache-Control` are always included by default, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. ```php 'cache_key_request_headers' => [ 'X-Request-Scope' ], ``` -**Security warning:** Add every query-specific header that can affect authentication, authorization, tenancy, or the returned data. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. Prefer the [data-source configuration](data-source.md#cache_key_request_headers-array) when a header applies to every query for that source. +**Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. ### request_body: array|callable diff --git a/inc/Config/CacheKeyRequestHeadersInterface.php b/inc/Config/CacheKeyRequestHeadersInterface.php deleted file mode 100644 index fce87af80..000000000 --- a/inc/Config/CacheKeyRequestHeadersInterface.php +++ /dev/null @@ -1,12 +0,0 @@ - Request header names included in cache keys. - */ - public function get_cache_key_request_headers(): array; -} diff --git a/inc/Config/DataSource/HttpDataSource.php b/inc/Config/DataSource/HttpDataSource.php index 0b88d69b5..a851e3047 100644 --- a/inc/Config/DataSource/HttpDataSource.php +++ b/inc/Config/DataSource/HttpDataSource.php @@ -3,7 +3,6 @@ namespace RemoteDataBlocks\Config\DataSource; use RemoteDataBlocks\Config\ArraySerializable; -use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Validation\ConfigSchemas; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; use WP_Error; @@ -13,11 +12,7 @@ * * Implements the HttpDataSourceInterface to define a generic HTTP data source. */ -class HttpDataSource extends ArraySerializable implements HttpDataSourceInterface, CacheKeyRequestHeadersInterface { - public function get_cache_key_request_headers(): array { - return $this->config['cache_key_request_headers'] ?? []; - } - +class HttpDataSource extends ArraySerializable implements HttpDataSourceInterface { final public function get_display_name(): string { return $this->config['display_name']; } diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index 09a90cc5f..2314aa523 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -3,7 +3,6 @@ namespace RemoteDataBlocks\Config\Query; use RemoteDataBlocks\Config\ArraySerializable; -use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Config\DataSource\HttpDataSourceInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; @@ -18,7 +17,7 @@ * * This class can be used to implement most HTTP queries. */ -class HttpQuery extends ArraySerializable implements HttpQueryInterface, CacheKeyRequestHeadersInterface { +class HttpQuery extends ArraySerializable implements HttpQueryInterface { /** * Execute the query with the provided input variables. Execution can be * customized by providing a custom query runner. @@ -46,12 +45,8 @@ public function execute_batch( array $array_of_input_variables ): array|WP_Error * @return array Request header names included in cache keys. */ public function get_cache_key_request_headers(): array { - $data_source = $this->get_data_source(); - $data_source_headers = $data_source instanceof CacheKeyRequestHeadersInterface ? $data_source->get_cache_key_request_headers() : []; - return CacheKeyRequestHeaders::merge( CacheKeyRequestHeaders::DEFAULT_HEADERS, - $data_source_headers, $this->config['cache_key_request_headers'] ?? [] ); } diff --git a/inc/Config/Query/HttpQueryInterface.php b/inc/Config/Query/HttpQueryInterface.php index 00e841080..ccf0633c4 100644 --- a/inc/Config/Query/HttpQueryInterface.php +++ b/inc/Config/Query/HttpQueryInterface.php @@ -13,6 +13,14 @@ interface HttpQueryInterface extends QueryInterface { public function get_data_source(): HttpDataSourceInterface; public function get_cache_ttl( array $input_variables ): null|int; + + /** + * Get the request header names whose values should be included in cache keys. + * + * @return array Request header names included in cache keys. + */ + public function get_cache_key_request_headers(): array; + public function get_endpoint( array $input_variables ): string; public function get_request_method(): string; public function get_request_headers( array $input_variables ): array|WP_Error; diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 17a014318..b7db3e40b 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -4,7 +4,6 @@ use Exception; use GuzzleHttp\RequestOptions; -use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Editor\DataBinding\Pagination; use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; @@ -60,13 +59,9 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $body = $query->get_request_body( $input_variables ); $endpoint = $query->get_endpoint( $input_variables ); $cache_ttl = $query->get_cache_ttl( $input_variables ); - $data_source = $query->get_data_source(); - $data_source_cache_key_request_headers = $data_source instanceof CacheKeyRequestHeadersInterface ? $data_source->get_cache_key_request_headers() : []; - $query_cache_key_request_headers = $query instanceof CacheKeyRequestHeadersInterface ? $query->get_cache_key_request_headers() : []; $cache_key_request_headers = CacheKeyRequestHeaders::merge( CacheKeyRequestHeaders::DEFAULT_HEADERS, - $data_source_cache_key_request_headers, - $query_cache_key_request_headers + $query->get_cache_key_request_headers() ); $parsed_url = wp_parse_url( $endpoint ); diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index 191e20343..a050749cf 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -23,12 +23,9 @@ class RdbCacheStrategy extends GreedyCacheStrategy { private const FALLBACK_CACHE_TTL_IN_SECONDS = 300; // 5 minutes for success responses public function __construct( ?CacheStorageInterface $storage = null ) { - $vary_headers = new KeyValueHttpHeader( CacheKeyRequestHeaders::DEFAULT_HEADERS ); - parent::__construct( $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ), - self::FALLBACK_CACHE_TTL_IN_SECONDS, - $vary_headers + self::FALLBACK_CACHE_TTL_IN_SECONDS ); } @@ -86,8 +83,8 @@ protected function getCacheObject( RequestInterface $request, ResponseInterface $jitter = intval( ceil( min( $ttl * 0.1, 20 ) ) ); $ttl = intval( $ttl ) + wp_rand( 0, $jitter ); - // NOTE: We skip the vary headers '*' check from the parent method - // since our defined vary headers cannot accept a '*' value. + // Cache-key request headers are resolved per request in getCacheKey(), so + // the parent's static vary-header check does not apply here. $response = $response->withoutHeader( 'Etag' )->withoutHeader( 'Last-Modified' ); diff --git a/inc/Integrations/GenericHttp/GenericHttpDataSource.php b/inc/Integrations/GenericHttp/GenericHttpDataSource.php index 257469b6a..87f2d34a6 100644 --- a/inc/Integrations/GenericHttp/GenericHttpDataSource.php +++ b/inc/Integrations/GenericHttp/GenericHttpDataSource.php @@ -3,7 +3,6 @@ namespace RemoteDataBlocks\Integrations\GenericHttp; use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\Validation\Types; use RemoteDataBlocks\Validation\Validator; use RemoteDataBlocks\Validation\ConfigSchemas; @@ -24,7 +23,6 @@ protected static function get_service_config_schema(): array { 'value' => Types::skip_sanitize( Types::string() ), ] ) ), - 'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ), 'display_name' => Types::string(), 'endpoint' => Types::string(), ] ); @@ -66,20 +64,12 @@ public static function get_request_headers_from_service_config( array $service_c return []; } - public static function get_cache_key_request_headers_from_service_config( array $service_config ): array { - return CacheKeyRequestHeaders::merge( - array_keys( self::get_request_headers_from_service_config( $service_config ) ), - $service_config['cache_key_request_headers'] ?? [] - ); - } - final public function get_service_name(): string { return static::SERVICE_NAME; } protected static function map_service_config( array $service_config ): array { return [ - 'cache_key_request_headers' => self::get_cache_key_request_headers_from_service_config( $service_config ), 'display_name' => $service_config['display_name'], 'endpoint' => self::get_endpoint_from_service_config( $service_config ), 'request_headers' => self::get_request_headers_from_service_config( $service_config ), diff --git a/inc/Integrations/Shopify/ShopifyDataSource.php b/inc/Integrations/Shopify/ShopifyDataSource.php index c79b1a718..5f1eae362 100644 --- a/inc/Integrations/Shopify/ShopifyDataSource.php +++ b/inc/Integrations/Shopify/ShopifyDataSource.php @@ -35,7 +35,6 @@ protected static function map_service_config( array $service_config ): array { } return [ - 'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ], 'display_name' => $service_config['display_name'], 'endpoint' => $endpoint, 'image_url' => plugins_url( './assets/shopify_logo_black.png', __FILE__ ), diff --git a/inc/Integrations/Shopify/ShopifyIntegration.php b/inc/Integrations/Shopify/ShopifyIntegration.php index e980a88b9..aa148eb02 100644 --- a/inc/Integrations/Shopify/ShopifyIntegration.php +++ b/inc/Integrations/Shopify/ShopifyIntegration.php @@ -30,6 +30,7 @@ public static function register_blocks(): void { public static function get_queries( ShopifyDataSource $data_source ): array { return [ 'shopify_get_product' => GraphqlQuery::from_array( [ + 'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ], 'display_name' => 'Get Shopify product by ID', 'data_source' => $data_source, 'input_schema' => [ @@ -88,6 +89,7 @@ public static function get_queries( ShopifyDataSource $data_source ): array { 'graphql_query' => file_get_contents( __DIR__ . '/Queries/GetProductById.graphql' ), ] ), 'shopify_search_products' => GraphqlQuery::from_array( [ + 'cache_key_request_headers' => [ 'X-Shopify-Storefront-Access-Token' ], 'display_name' => 'Search Shopify products', 'data_source' => $data_source, 'input_schema' => [ diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php index 782b4229f..8b443464d 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -133,7 +133,6 @@ private static function generate_graphql_query_config_schema(): array { private static function generate_http_data_source_config_schema(): array { return Types::object( [ - 'cache_key_request_headers' => Types::nullable( Types::list_of( Types::string() ) ), 'display_name' => Types::string(), 'endpoint' => Types::string(), 'image_url' => Types::nullable( Types::image_url() ), diff --git a/src/data-sources/http/HttpSettings.tsx b/src/data-sources/http/HttpSettings.tsx index f9d025913..ef7396ec4 100644 --- a/src/data-sources/http/HttpSettings.tsx +++ b/src/data-sources/http/HttpSettings.tsx @@ -2,7 +2,6 @@ import { Card, CardBody, ExternalLink, TextControl, Tip } from '@wordpress/compo import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; -import { CustomFormFieldToken } from '@/data-sources/components/CustomFormFieldToken'; import { HttpAuthSettingsInput } from '@/data-sources/components/HttpAuthSettingsInput'; import { ConfigSource } from '@/data-sources/constants'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; @@ -94,26 +93,6 @@ export const HttpSettings = ( { mode, uuid, config }: SettingsComponentProps< Ht /> - ) => { - handleOnChange( - 'cache_key_request_headers', - headers.map( header => ( 'object' === typeof header ? header.value : header ) ) - ); - } } - suggestions={ [] } - __experimentalValidateInput={ ( input: string ) => - /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/.test( input ) - } - __nextHasNoMarginBottom - __next40pxDefaultSize - customHelpText={ __( - 'Custom authentication headers configured above are included automatically. Add every other header that can affect authentication, authorization, tenancy, or returned data. Omitting one can expose cached data across security contexts.', - 'remote-data-blocks' - ) } - /> diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts index 7b9ca3ca5..9f5124425 100644 --- a/src/data-sources/types.ts +++ b/src/data-sources/types.ts @@ -57,7 +57,6 @@ export interface GoogleSheetsServiceConfig extends BaseServiceConfig { export interface HttpServiceConfig extends BaseServiceConfig { auth?: HttpAuth; - cache_key_request_headers?: string[]; endpoint: string; } diff --git a/tests/inc/Config/HttpDataSourceTest.php b/tests/inc/Config/HttpDataSourceTest.php index 2b71fabe8..051b63a1e 100644 --- a/tests/inc/Config/HttpDataSourceTest.php +++ b/tests/inc/Config/HttpDataSourceTest.php @@ -3,22 +3,10 @@ namespace RemoteDataBlocks\Tests\Config; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Tests\Mocks\MockDataSource; use WP_Error; class HttpDataSourceTest extends TestCase { - public function testCacheKeyRequestHeadersCanBeConfigured(): void { - $data_source = HttpDataSource::from_array( [ - 'display_name' => 'Custom API', - 'endpoint' => 'https://example.com/api', - 'cache_key_request_headers' => [ 'X-Api-Key', 'X-Tenant-ID' ], - ] ); - - $this->assertInstanceOf( HttpDataSource::class, $data_source ); - $this->assertSame( [ 'X-Api-Key', 'X-Tenant-ID' ], $data_source->get_cache_key_request_headers() ); - } - public function test_migrate_config_moves_header(): void { $config = [ 'display_name' => 'Mock Data Source', diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index 40a23daf3..d2bd228fb 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -5,7 +5,6 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\CacheKeyRequestHeadersInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\HttpClient\HttpClient; @@ -97,13 +96,13 @@ public function testExecuteSuccessfulRequest( string $endpoint ): void { } public function testRequestDetailsIncludeCacheKeyRequestHeadersOption(): void { - $data_source = MockDataSource::create( array_merge( - MockDataSource::MOCK_CONFIG, - [ 'cache_key_request_headers' => [ 'X-Api-Key' ] ] - ) ); + $data_source = MockDataSource::create(); $this->assertInstanceOf( MockDataSource::class, $data_source ); - $query = MockQuery::create( [ 'data_source' => $data_source ] ); + $query = MockQuery::create( [ + 'cache_key_request_headers' => [ 'X-Api-Key' ], + 'data_source' => $data_source, + ] ); $this->assertInstanceOf( MockQuery::class, $query ); $query_runner = new class($this->http_client, []) extends QueryRunner { @@ -116,36 +115,6 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array $this->assertIsArray( $request_details ); $this->assertSame( [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], - $request_details['options']['remote_data_blocks_cache_key_request_headers'] ?? null - ); - } - - public function testRequestDetailsIncludeDataSourceHeadersForLegacyCustomQuery(): void { - $data_source = MockDataSource::create( array_merge( - MockDataSource::MOCK_CONFIG, - [ 'cache_key_request_headers' => [ 'X-Tenant-ID' ] ] - ) ); - $this->assertInstanceOf( MockDataSource::class, $data_source ); - - $query = $this->createMock( HttpQueryInterface::class ); - $this->assertNotInstanceOf( CacheKeyRequestHeadersInterface::class, $query ); - $query->method( 'get_data_source' )->willReturn( $data_source ); - $query->method( 'get_request_headers' )->willReturn( [] ); - $query->method( 'get_request_method' )->willReturn( 'GET' ); - $query->method( 'get_request_body' )->willReturn( null ); - $query->method( 'get_endpoint' )->willReturn( 'https://example.com/api' ); - $query->method( 'get_cache_ttl' )->willReturn( null ); - - $query_runner = new class($this->http_client, []) extends QueryRunner { - public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error { - return $this->get_request_details( $query, [] ); - } - }; - - $request_details = $query_runner->get_request_details_for_test( $query ); - $this->assertIsArray( $request_details ); - $this->assertSame( - [ 'Authorization', 'Cache-Control', 'X-Tenant-ID' ], $request_details['options'][ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? null ); } diff --git a/tests/inc/Config/QueryTest.php b/tests/inc/Config/QueryTest.php index 52df4196e..9cb7d8647 100644 --- a/tests/inc/Config/QueryTest.php +++ b/tests/inc/Config/QueryTest.php @@ -38,7 +38,7 @@ public function testGetRequestHeaders(): void { $this->assertSame( [ 'Content-Type' => 'application/json' ], $result ); } - public function testCacheKeyRequestHeadersMergeDefaultsDataSourceAndQuery(): void { + public function testCacheKeyRequestHeadersMergeDefaultsAndQueryOnly(): void { $data_source = HttpDataSource::from_array( [ 'display_name' => 'Custom API', 'endpoint' => 'https://example.com/api', @@ -54,7 +54,7 @@ public function testCacheKeyRequestHeadersMergeDefaultsDataSourceAndQuery(): voi $this->assertInstanceOf( HttpQuery::class, $query ); $this->assertSame( - [ 'Authorization', 'Cache-Control', 'X-Api-Key', 'x-tenant-id', 'X-Request-Scope' ], + [ 'Authorization', 'Cache-Control', 'x-api-key', 'X-Request-Scope' ], $query->get_cache_key_request_headers() ); } diff --git a/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php b/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php index e3782a16f..c2510b7af 100644 --- a/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php +++ b/tests/inc/Integrations/GenericHttp/GenericHttpDataSourceTest.php @@ -4,7 +4,6 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Integrations\GenericHttp\GenericHttpDataSource; -use WP_Error; class GenericHttpDataSourceTest extends TestCase { @@ -38,24 +37,4 @@ public function test_to_array_returns_correctly_mapped_values(): void { $this->assertEquals( 'generic-http', $data_source_array['service'] ); $this->assertEquals( 'http://example.com', $data_source_array['service_config']['endpoint'] ); } - - public function testCacheKeyRequestHeadersIncludeApiKeyHeaderAndManualAdditions(): void { - $data_source = GenericHttpDataSource::from_array( [ - 'service_config' => [ - '__version' => 1, - 'auth' => [ - 'add_to' => 'header', - 'key' => 'X-Api-Key', - 'type' => 'api-key', - 'value' => 'secret', - ], - 'cache_key_request_headers' => [ 'X-Tenant-ID', 'x-api-key' ], - 'display_name' => 'Mock Data Source', - 'endpoint' => 'https://example.com', - ], - ] ); - - $this->assertNotInstanceOf( WP_Error::class, $data_source ); - $this->assertSame( [ 'X-Api-Key', 'X-Tenant-ID' ], $data_source->get_cache_key_request_headers() ); - } } diff --git a/tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php b/tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php similarity index 52% rename from tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php rename to tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php index 3d82b6fd7..6e248b371 100644 --- a/tests/inc/Integrations/Shopify/ShopifyDataSourceTest.php +++ b/tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php @@ -4,10 +4,11 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Integrations\Shopify\ShopifyDataSource; +use RemoteDataBlocks\Integrations\Shopify\ShopifyIntegration; use WP_Error; -class ShopifyDataSourceTest extends TestCase { - public function testStorefrontAccessTokenHeaderIsIncludedInCacheKey(): void { +class ShopifyIntegrationTest extends TestCase { + public function testQueriesIncludeStorefrontAccessTokenHeaderInCacheKey(): void { $data_source = ShopifyDataSource::from_array( [ 'service_config' => [ '__version' => 1, @@ -18,9 +19,13 @@ public function testStorefrontAccessTokenHeaderIsIncludedInCacheKey(): void { ] ); $this->assertNotInstanceOf( WP_Error::class, $data_source ); - $this->assertSame( - [ 'X-Shopify-Storefront-Access-Token' ], - $data_source->get_cache_key_request_headers() - ); + $queries = ShopifyIntegration::get_queries( $data_source ); + + foreach ( $queries as $query ) { + $this->assertSame( + [ 'X-Shopify-Storefront-Access-Token' ], + $query->to_array()['cache_key_request_headers'] ?? null + ); + } } } diff --git a/tests/inc/Mocks/MockQuery.php b/tests/inc/Mocks/MockQuery.php index 3f83606be..e8c2468b5 100644 --- a/tests/inc/Mocks/MockQuery.php +++ b/tests/inc/Mocks/MockQuery.php @@ -13,6 +13,7 @@ class MockQuery extends HttpQuery { public static function create( array $config = [], ?ValidatorInterface $validator = null ): static|WP_Error { return self::from_array( [ + 'cache_key_request_headers' => $config['cache_key_request_headers'] ?? null, 'data_source' => $config['data_source'] ?? MockDataSource::create(), 'display_name' => 'Mock Query', 'endpoint' => $config['endpoint'] ?? null, diff --git a/tests/src/data-sources/http/HttpSettings.test.tsx b/tests/src/data-sources/http/HttpSettings.test.tsx deleted file mode 100644 index ad50c8f4e..000000000 --- a/tests/src/data-sources/http/HttpSettings.test.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -import { ConfigSource } from '@/data-sources/constants'; -import { HttpSettings } from '@/data-sources/http/HttpSettings'; -import { HttpConfig } from '@/data-sources/types'; -import { SettingsContext } from '@/settings/hooks/useSettingsNav'; - -const mocks = vi.hoisted( () => ( { - canUseDisplayName: vi.fn( () => true ), - onSave: vi.fn( async () => undefined ), -} ) ); - -vi.mock( '@/data-sources/hooks/useDataSources', () => ( { - useDataSources: () => ( { - canUseDisplayName: mocks.canUseDisplayName, - onSave: mocks.onSave, - } ), -} ) ); - -describe( 'HttpSettings', () => { - beforeEach( () => { - mocks.onSave.mockClear(); - document.body.innerHTML = '
'; - } ); - - it( 'saves additional cache key request headers', async () => { - const user = userEvent.setup(); - const config: HttpConfig = { - config_source: ConfigSource.STORAGE, - service: 'generic-http', - service_config: { - __version: 1, - auth: { type: 'none', value: '' }, - cache_key_request_headers: [], - display_name: 'Custom API', - enable_blocks: false, - endpoint: 'https://example.com', - }, - uuid: '00000000-0000-4000-8000-000000000000', - }; - - render( - - - - ); - - const headerInput = screen.getByRole( 'combobox', { - name: 'Additional cache key headers', - } ); - await user.type( headerInput, 'X-Tenant-ID{enter}' ); - await user.click( screen.getByRole( 'button', { name: 'Save' } ) ); - - expect( mocks.onSave ).toHaveBeenCalledWith( - expect.objectContaining( { - service_config: expect.objectContaining( { - cache_key_request_headers: [ 'X-Tenant-ID' ], - } ), - } ), - 'edit' - ); - } ); -} ); From 4981d3f56b91a2f1d775383e54d1b31e82eeca8b Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 13:02:49 -0500 Subject: [PATCH 07/18] Document HttpQueryInterface upgrade --- docs/extending/query.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/extending/query.md b/docs/extending/query.md index 90cc25089..25337f407 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -149,6 +149,14 @@ A static list of additional request header names whose values will be included i **Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. +If you implement `HttpQueryInterface` directly, implement `get_cache_key_request_headers()`. Return an empty array to use only the built-in `Authorization` and `Cache-Control` defaults, or return additional header names for that query: + +```php +public function get_cache_key_request_headers(): array { + return [ 'X-Api-Key' ]; +} +``` + ### request_body: array|callable The `request_body` property defines the request body for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will not have a request body. From dafd9197dc07f1604b7cde6f53545ac09fad6c8e Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 13:21:45 -0500 Subject: [PATCH 08/18] Simplify cache key header metadata flow --- inc/Config/QueryRunner/QueryRunner.php | 5 +- inc/HttpClient/RdbCacheMiddleware.php | 14 +----- inc/HttpClient/RdbCacheStrategy.php | 13 ++--- inc/HttpClient/RdbLogMiddleware.php | 22 ++++----- tests/inc/Config/QueryRunnerTest.php | 5 +- tests/inc/HttpClient/HttpClientTest.php | 48 ++++++++++++++----- tests/inc/HttpClient/RdbLogMiddlewareTest.php | 14 ++++-- 7 files changed, 71 insertions(+), 50 deletions(-) diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index b7db3e40b..1bf8bf0cf 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -97,7 +97,9 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $pass = ( $user || $pass ) ? $pass . '@' : ''; $origin = sprintf( '%s://%s%s%s%s', $scheme, $user, $pass, $host, $port ); - $cache_headers = []; + $cache_headers = [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => $cache_key_request_headers, + ]; if ( intval( $cache_ttl ) > 0 ) { $cache_headers[ RdbCacheStrategy::CACHE_TTL_REQUEST_HEADER ] = $cache_ttl; } @@ -108,7 +110,6 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $request_details = [ 'method' => $method, 'options' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => $cache_key_request_headers, RequestOptions::HEADERS => array_merge( $headers, $cache_headers ), RequestOptions::JSON => $body, ], diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index c974dd34a..969dd20e2 100644 --- a/inc/HttpClient/RdbCacheMiddleware.php +++ b/inc/HttpClient/RdbCacheMiddleware.php @@ -6,25 +6,13 @@ class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware { public const CACHE_KEY_REQUEST_HEADERS_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; - public const CACHE_KEY_REQUEST_HEADERS_OPTION = 'remote_data_blocks_cache_key_request_headers'; public function __invoke( callable $handler ): callable { $handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) { return $handler( $request->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_HEADER ), $options ); }; - $cache_handler = parent::__invoke( $handler_without_cache_metadata ); - return function ( RequestInterface $request, array $options ) use ( $cache_handler ) { - $cache_key_request_headers = $options[ self::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? []; - unset( $options[ self::CACHE_KEY_REQUEST_HEADERS_OPTION ] ); - - if ( is_array( $cache_key_request_headers ) ) { - $cache_key_request_headers = array_values( array_filter( $cache_key_request_headers, 'is_string' ) ); - $request = $request->withHeader( self::CACHE_KEY_REQUEST_HEADERS_HEADER, $cache_key_request_headers ); - } - - return $cache_handler( $request, $options ); - }; + return parent::__invoke( $handler_without_cache_metadata ); } /** diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index a050749cf..e713b0033 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -29,17 +29,14 @@ public function __construct( ?CacheStorageInterface $storage = null ) { ); } - /** - * @param array|null $cache_key_request_headers Request headers included in the cache key. When omitted, read them from the request metadata header. - */ - public static function get_object_cache_key_from_request( RequestInterface $request, ?array $cache_key_request_headers = null ): string { + public static function get_object_cache_key_from_request( RequestInterface $request ): string { $request_body = (string) $request->getBody(); $request_method = $request->getMethod(); $request_uri = (string) $request->getUri(); $cache_key_request_headers = CacheKeyRequestHeaders::merge( CacheKeyRequestHeaders::DEFAULT_HEADERS, - $cache_key_request_headers ?? $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) + $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); $cache_headers = []; @@ -88,6 +85,10 @@ protected function getCacheObject( RequestInterface $request, ResponseInterface $response = $response->withoutHeader( 'Etag' )->withoutHeader( 'Last-Modified' ); - return new CacheEntry( $request->withoutHeader( static::HEADER_TTL ), $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); + $cache_request = $request + ->withoutHeader( static::HEADER_TTL ) + ->withoutHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ); + + return new CacheEntry( $cache_request, $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); } } diff --git a/inc/HttpClient/RdbLogMiddleware.php b/inc/HttpClient/RdbLogMiddleware.php index 593266e88..bb760e58d 100644 --- a/inc/HttpClient/RdbLogMiddleware.php +++ b/inc/HttpClient/RdbLogMiddleware.php @@ -27,22 +27,20 @@ public function __invoke( callable $handler ): callable { return function ( RequestInterface $request, array &$options ) use ( $handler ): PromiseInterface { return $handler( $request, $options ) ->then( - $this->handle_success( $request, $options ), - $this->handle_failure( $request, $options ) + $this->handle_success( $request ), + $this->handle_failure( $request ) ); }; } - private function log( RequestInterface $request, ?ResponseInterface $response, ?\Exception $reason, array $options ): void { + private function log( RequestInterface $request, ?ResponseInterface $response, ?\Exception $reason ): void { $response_headers = $response ? $response->getHeaders() : []; $uri = $request->getUri(); - $cache_key_request_headers = $options[ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? []; - $cache_key_request_headers = is_array( $cache_key_request_headers ) ? array_values( array_filter( $cache_key_request_headers, 'is_string' ) ) : []; $context = [ 'cache_age' => $response_headers[ RdbCacheStrategy::CACHE_AGE_RESPONSE_HEADER ][0] ?? '', 'cache_group' => RdbCacheStrategy::WP_OBJECT_CACHE_GROUP ?? '', - 'cache_key' => RdbCacheStrategy::get_object_cache_key_from_request( $request, $cache_key_request_headers ), + 'cache_key' => RdbCacheStrategy::get_object_cache_key_from_request( $request ), 'cache_status' => $response_headers[ CacheMiddleware::HEADER_CACHE_INFO ][0] ?? '', 'error' => $reason, 'hostname' => $uri->getHost(), @@ -59,10 +57,10 @@ private function log( RequestInterface $request, ?ResponseInterface $response, ? /** * Returns a function which is handled when a request was rejected. */ - private function handle_failure( RequestInterface $request, array $options ): callable { - return function ( \Exception $reason ) use ( $request, $options ) { + private function handle_failure( RequestInterface $request ): callable { + return function ( \Exception $reason ) use ( $request ) { $response = ( $reason instanceof RequestException && $reason->hasResponse() === true ) ? $reason->getResponse() : null; - $this->log( $request, $response, $reason, $options ); + $this->log( $request, $response, $reason ); return Create::rejectionFor( $reason ); }; } @@ -70,9 +68,9 @@ private function handle_failure( RequestInterface $request, array $options ): ca /** * Returns a function which is handled when a request was successful. */ - private function handle_success( RequestInterface $request, array $options ): callable { - return function ( ResponseInterface $response ) use ( $request, $options ) { - $this->log( $request, $response, null, $options ); + private function handle_success( RequestInterface $request ): callable { + return function ( ResponseInterface $response ) use ( $request ) { + $this->log( $request, $response, null ); return $response; }; } diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index d2bd228fb..99cde5f6d 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -3,6 +3,7 @@ namespace RemoteDataBlocks\Tests\Config; use GuzzleHttp\Psr7\Response; +use GuzzleHttp\RequestOptions; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Config\Query\HttpQueryInterface; @@ -95,7 +96,7 @@ public function testExecuteSuccessfulRequest( string $endpoint ): void { $this->assertArrayHasKey( 'results', $result ); } - public function testRequestDetailsIncludeCacheKeyRequestHeadersOption(): void { + public function testRequestDetailsIncludeCacheKeyRequestHeadersHeader(): void { $data_source = MockDataSource::create(); $this->assertInstanceOf( MockDataSource::class, $data_source ); @@ -115,7 +116,7 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array $this->assertIsArray( $request_details ); $this->assertSame( [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], - $request_details['options'][ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION ] ?? null + $request_details['options'][ RequestOptions::HEADERS ][ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ] ?? null ); } diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index 39131f3ba..626dc5cb9 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -7,7 +7,9 @@ use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Middleware; +use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; +use Kevinrob\GuzzleCache\CacheEntry; use Kevinrob\GuzzleCache\Storage\VolatileRuntimeStorage; use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; @@ -267,12 +269,16 @@ public function testConfiguredCustomHeaderWithDifferentValuesResultsInCacheMiss( ); $first_response = $this->http_client->request( 'GET', '/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], - 'headers' => [ 'X-Api-Key' => 'first-api-key' ], + 'headers' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + 'X-Api-Key' => 'first-api-key', + ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], - 'headers' => [ 'X-Api-Key' => 'second-api-key' ], + 'headers' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + 'X-Api-Key' => 'second-api-key', + ], ], $this->client ); $this->assertSame( 'First Response', (string) $first_response->getBody() ); @@ -289,12 +295,16 @@ public function testConfiguredCustomHeaderNameIsCaseInsensitive(): void { ); $first_response = $this->http_client->request( 'GET', '/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], - 'headers' => [ 'x-api-key' => 'first-api-key' ], + 'headers' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'x-api-key' => 'first-api-key', + ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], - 'headers' => [ 'x-api-key' => 'second-api-key' ], + 'headers' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'x-api-key' => 'second-api-key', + ], ], $this->client ); $this->assertSame( 'First Response', (string) $first_response->getBody() ); @@ -312,14 +322,30 @@ public function testCacheKeyRequestHeaderMetadataIsNotSentToRequestHandler(): vo $client = new Client( [ 'handler' => $handler_stack ] ); $this->http_client->request( 'GET', '/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ], - 'headers' => [ 'X-Api-Key' => 'secret' ], + 'headers' => [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'X-Api-Key' => 'secret', + ], ], $client ); $this->assertCount( 1, $transactions ); $this->assertSame( 'secret', $transactions[0]['request']->getHeaderLine( 'X-Api-Key' ) ); $this->assertFalse( $transactions[0]['request']->hasHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); - $this->assertArrayNotHasKey( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION, $transactions[0]['options'] ); + } + + public function testCacheKeyRequestHeaderMetadataIsNotStoredInCacheEntry(): void { + $storage = new VolatileRuntimeStorage(); + $strategy = new RdbCacheStrategy( $storage ); + $request = new Request( 'GET', 'https://example.com/test', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'X-Api-Key' => 'secret', + ] ); + + $strategy->cache( $request, new Response( 200 ) ); + $cache_entry = $strategy->fetch( $request ); + + $this->assertInstanceOf( CacheEntry::class, $cache_entry ); + $this->assertFalse( $cache_entry->getOriginalRequest()->hasHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); } public function testRepeatedPostRequestsWithDifferentBodyResultsInCacheMiss(): void { diff --git a/tests/inc/HttpClient/RdbLogMiddlewareTest.php b/tests/inc/HttpClient/RdbLogMiddlewareTest.php index 856716bc4..5156709ed 100644 --- a/tests/inc/HttpClient/RdbLogMiddlewareTest.php +++ b/tests/inc/HttpClient/RdbLogMiddlewareTest.php @@ -22,15 +22,21 @@ public function testConfiguredHeaderValuesProduceDifferentLoggedCacheKeys(): voi }; $log_handler = ( new RdbLogMiddleware() )( $handler ); - $first_options = [ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ] ]; + $first_options = []; $log_handler( - new Request( 'GET', 'https://example.com/data', [ 'X-Api-Key' => 'first-api-key' ] ), + new Request( 'GET', 'https://example.com/data', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'X-Api-Key' => 'first-api-key', + ] ), $first_options )->wait(); - $second_options = [ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_OPTION => [ 'X-Api-Key' ] ]; + $second_options = []; $log_handler( - new Request( 'GET', 'https://example.com/data', [ 'X-Api-Key' => 'second-api-key' ] ), + new Request( 'GET', 'https://example.com/data', [ + RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + 'X-Api-Key' => 'second-api-key', + ] ), $second_options )->wait(); From a1c7e51d8b17eac53064475706fdb748f315fafa Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 15:18:33 -0500 Subject: [PATCH 09/18] Apply suggestion from @chriszarate Co-authored-by: Chris Zarate --- inc/Config/Query/HttpQuery.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index 2314aa523..541b6cce2 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -45,10 +45,7 @@ public function execute_batch( array $array_of_input_variables ): array|WP_Error * @return array Request header names included in cache keys. */ public function get_cache_key_request_headers(): array { - return CacheKeyRequestHeaders::merge( - CacheKeyRequestHeaders::DEFAULT_HEADERS, - $this->config['cache_key_request_headers'] ?? [] - ); + return $this->config['cache_key_request_headers'] ?? []; } /** From bb1a106d08aa4d2f51739092aac44dd6a2466aca Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 15:46:23 -0500 Subject: [PATCH 10/18] Remove unused CacheKeyRequestHeaders reference --- inc/Config/Query/HttpQuery.php | 1 - 1 file changed, 1 deletion(-) diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index 541b6cce2..c0dcbd3ec 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -6,7 +6,6 @@ use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Config\DataSource\HttpDataSourceInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; -use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\Validation\ConfigSchemas; use WP_Error; From 44f1b259ceb7e9aa1a564b83c5015659bb1ca2ca Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 16:03:38 -0500 Subject: [PATCH 11/18] Update the shape of CacheKeyRequestHeaders::merge --- inc/Config/QueryRunner/QueryRunner.php | 1 - inc/HttpClient/CacheKeyRequestHeaders.php | 34 +++++++++++------------ inc/HttpClient/RdbCacheMiddleware.php | 12 -------- inc/HttpClient/RdbCacheStrategy.php | 6 ++-- 4 files changed, 19 insertions(+), 34 deletions(-) diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 1bf8bf0cf..3e6c6df82 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -60,7 +60,6 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $endpoint = $query->get_endpoint( $input_variables ); $cache_ttl = $query->get_cache_ttl( $input_variables ); $cache_key_request_headers = CacheKeyRequestHeaders::merge( - CacheKeyRequestHeaders::DEFAULT_HEADERS, $query->get_cache_key_request_headers() ); $parsed_url = wp_parse_url( $endpoint ); diff --git a/inc/HttpClient/CacheKeyRequestHeaders.php b/inc/HttpClient/CacheKeyRequestHeaders.php index 233514790..e97165230 100644 --- a/inc/HttpClient/CacheKeyRequestHeaders.php +++ b/inc/HttpClient/CacheKeyRequestHeaders.php @@ -3,30 +3,28 @@ namespace RemoteDataBlocks\HttpClient; final class CacheKeyRequestHeaders { - public const DEFAULT_HEADERS = [ 'Authorization', 'Cache-Control' ]; + private const DEFAULT_HEADERS = [ 'Authorization', 'Cache-Control' ]; /** - * Merge request header name lists without case-insensitive duplicates. + * Merge the given header list with DEFAULT_HEADERS, removing case-insensitive duplicates. * - * @param array ...$header_lists Request header name lists. + * @param array $headers Request header names to merge with DEFAULT_HEADERS. * @return array Merged request header names. */ - public static function merge( array ...$header_lists ): array { - $merged_headers = []; - $seen_headers = []; + public static function merge( array $headers ): array { + # Start with DEFAULT_HEADERS + $seen_headers = array_fill_keys( + array_map( 'strtolower', self::DEFAULT_HEADERS ), + true + ); - foreach ( $header_lists as $header_list ) { - foreach ( $header_list as $header ) { - $normalized_header = strtolower( $header ); - if ( isset( $seen_headers[ $normalized_header ] ) ) { - continue; - } + # Add extra headers, skipping any duplicates (case-insensitive) + $seen_headers = array_merge( + $seen_headers, + array_fill_keys( array_map( 'strtolower', $headers ), true ) + ); - $seen_headers[ $normalized_header ] = true; - $merged_headers[] = $header; - } - } - - return $merged_headers; + # Return the unique header keys + return array_keys( $seen_headers ); } } diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index 969dd20e2..88411f4bd 100644 --- a/inc/HttpClient/RdbCacheMiddleware.php +++ b/inc/HttpClient/RdbCacheMiddleware.php @@ -2,19 +2,7 @@ namespace RemoteDataBlocks\HttpClient; -use Psr\Http\Message\RequestInterface; - class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware { - public const CACHE_KEY_REQUEST_HEADERS_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; - - public function __invoke( callable $handler ): callable { - $handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) { - return $handler( $request->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_HEADER ), $options ); - }; - - return parent::__invoke( $handler_without_cache_metadata ); - } - /** * @var array */ diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index e713b0033..de0c9c8f1 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -17,6 +17,7 @@ class RdbCacheStrategy extends GreedyCacheStrategy { public const CACHE_AGE_RESPONSE_HEADER = 'Age'; public const CACHE_STATUS_RESPONSE_HEADER = CacheMiddleware::HEADER_CACHE_INFO; public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL; + public const CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks'; private const ERROR_CACHE_TTL_IN_SECONDS = 30; // 30 seconds for error responses @@ -35,8 +36,7 @@ public static function get_object_cache_key_from_request( RequestInterface $requ $request_uri = (string) $request->getUri(); $cache_key_request_headers = CacheKeyRequestHeaders::merge( - CacheKeyRequestHeaders::DEFAULT_HEADERS, - $request->getHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) + $request->getHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) ); $cache_headers = []; @@ -87,7 +87,7 @@ protected function getCacheObject( RequestInterface $request, ResponseInterface $cache_request = $request ->withoutHeader( static::HEADER_TTL ) - ->withoutHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ); + ->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ); return new CacheEntry( $cache_request, $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); } From 1738a00078bf6c354530921a2ac7c4570f0e7469 Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 16:11:52 -0500 Subject: [PATCH 12/18] Fix local review issues and old constant refs --- docs/for-ai.md | 3990 +++++++++-------- .../github-markdown-block.php | 1 + .../rest-api-block-from-ui-data-source.php | 10 + .../rest-api-block/rest-api-block.php | 6 + inc/Config/QueryRunner/QueryRunner.php | 3 +- inc/HttpClient/CacheKeyRequestHeaders.php | 19 +- inc/HttpClient/RdbCacheMiddleware.php | 10 + tests/inc/Config/QueryRunnerTest.php | 4 +- tests/inc/Config/QueryTest.php | 4 +- tests/inc/HttpClient/HttpClientTest.php | 36 +- tests/inc/HttpClient/RdbLogMiddlewareTest.php | 6 +- 11 files changed, 2164 insertions(+), 1925 deletions(-) diff --git a/docs/for-ai.md b/docs/for-ai.md index e9d3a3d4a..e2d117673 100644 --- a/docs/for-ai.md +++ b/docs/for-ai.md @@ -3,7 +3,7 @@ This file is a merged representation of a subset of the codebase, containing spe # File Summary ## Purpose -This file contains a packed representation of the entire repository's contents. +This file contains a packed representation of a subset of the repository's contents that is considered the most important context. It is designed to be easily consumable by AI systems for analysis, code review, or other automated processes. @@ -35,7 +35,7 @@ The content is organized as follows: - Files are sorted by Git change count (files with more changes are at the bottom) # Directory Structure -``` +```` docs/ concepts/ block-bindings.md @@ -114,77 +114,87 @@ example/ theme/ functions.php README.md + screenshot.png style-remote-data-blocks.css style.css theme.json README.md -``` +```` # Files -## File: docs/extending/overrides.md +## File: docs/concepts/block-bindings.md ````markdown -# Overrides +# Block bindings -Overrides provide a way to customize the behavior of remote data blocks on a per-block basis. You can use them to modify the underlying query input variables, adjust the query response, or change the caching behavior. Overrides are defined when you register a remote data block and can be enabled or disabled via the block settings in the WordPress editor. +Remote Data Blocks takes advantage of the [block bindings API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). This core WordPress API allows you to “bind” dynamic data to the attributes of core blocks, which are then reflected in the final HTML markup. Generally, this avoids the need to write and maintain custom blocks. -If you have multiple instances of the same remote data block in a piece of content, each instance can have different overrides enabled. By default, no overrides are enabled. +For a quick overview of block bindings, the [announcement post](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/) is very helpful; for a deeper dive, consult the [public documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). That said, an in-depth understanding of block bindings isn't necessary to use Remote Data Blocks: just know that the plugin is built on core, stable WordPress APIs. +```` -Here is an example of an override that modifies the query input variables based on the URL. +## File: docs/concepts/helper-blocks.md +````markdown +# Helper Blocks -You could use this to build a "product page" in the WordPress admin that would be able to display any product, using an ID from the URL, e.g.: https://example.com/product/123456 +Remote Data Blocks adds some accessory blocks for bindings, listed below. -The example takes advantage of the [`add_rewrite_rule`](https://developer.wordpress.org/reference/functions/add_rewrite_rule/) function and the [`query_vars`](https://developer.wordpress.org/reference/hooks/query_vars/) filter that are built into WordPress. +## Remote HTML Block + +Use this block to bind to HTML from a remote data source. This block only works when placed inside a remote data block container and bound to a field containing HTML. + +![Screen recording showing the insertion and binding of a Remote HTML Block in the editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/block-insert-remote-html.gif) + +Fields defined by a query’s `output_schema` must have type `html` in order to be available to Remote HTML blocks: ```php +$my_query = [ + /* ... */ + 'output_schema' => + 'is_collection' => false, + 'output_schema' => [ + 'type' => [ + 'header' => [ + 'name' => 'Header', + 'path' => '$.header', + 'type' => 'string', + ], + 'myHtmlContent' => [ + 'name' => 'My HTML Content', + 'path' => '$.myHtmlContent', + 'type' => 'html', // <-- required + ], + ], + ], +]; + register_remote_data_block( [ - 'title' => 'Acme Product', + 'title' => 'My HTML API', 'render_query' => [ - 'query' => $get_product_query, - ], - 'overrides' => [ - [ - 'name' => 'product_id_override', - 'display_name' => __( 'Use product ID from URL', 'my-text-domain' ), - 'help_text' => __( 'For use on the /products/ page', 'my-text-domain' ), - ], + 'query' => $my_query, ], ] ); +``` -add_rewrite_rule( '^products/([0-9]+)/?', 'index.php?pagename=products&acme_product_id=$matches[1]', 'top' ); - -add_filter( 'query_vars', function ( array $query_vars ): array { - $query_vars[] = 'acme_product_id'; - return $query_vars; -}, 10, 1 ); - -add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides ): array { - if ( true === in_array( 'product_id_override', $enabled_overrides, true ) ) { - $product_id = get_query_var( 'acme_product_id' ); +## No Results Block - if ( ! empty( $product_id ) ) { - $input_variables['product_id'] = $product_id; - } - } +This block is used to display a message or content when a remote data block query returns no results. It is automatically inserted whenever you use a query that resolves to a collection, even if the collection is not currently empty. +```` - return $input_variables; -}, 10, 2 ); -``` +## File: docs/concepts/inline-bindings.md +````markdown +# Inline bindings -As you can see, the `remote_data_blocks_query_input_variables` filter is passed a list of enabled overrides. You need to add logic to identify which filters are enabled and act accordingly. +One of the current limitations of the [block bindings API](block-bindings.md) is that it is restricted to a small number of core blocks and attributes. For example, currently, you cannot bind to the content of a table block or a custom block. You also cannot bind to a _subset_ of a block's content. -The `overrides` property in the block registration array enables a panel in the block settings that allows content authors to enable or disable the override: +As a partial workaround, this plugin provides a way to use remote data in some places where block bindings are not supported. This feature is named "inline bindings" and it is available in any block that uses [rich text](https://developer.wordpress.org/block-editor/reference-guides/richtext/), such as tables, lists, and some custom blocks. Look for the inline binding button in the rich text formatting toolbar: -An overrides panel in a remote data block settings panel -```` +Inline binding button -## File: docs/concepts/block-bindings.md -````markdown -# Block bindings +Clicking this button will open a modal that allows you to select a field from a remote data source, resulting in an inline remote data binding. Just like remote data blocks, this binding will resolve from the remote source when the content is rendered. -Remote Data Blocks takes advantage of the [block bindings API](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). This core WordPress API allows you to “bind” dynamic data to the attributes of core blocks, which are then reflected in the final HTML markup. Generally, this avoids the need to write and maintain custom blocks. +A bulleted list using several inline bindings to describe three conference events -For a quick overview of block bindings, the [announcement post](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/) is very helpful; for a deeper dive, consult the [public documentation](https://developer.wordpress.org/block-editor/reference-guides/block-api/block-bindings/). That said, an in-depth understanding of block bindings isn't necessary to use Remote Data Blocks: just know that the plugin is built on core, stable WordPress APIs. +Inline bindings compile to HTML, so they are portable, safe, and have a built-in fallback. ```` ## File: docs/extending/block-patterns.md @@ -234,238 +244,753 @@ register_remote_data_block( [ ``` ```` -## File: docs/tutorials/airtable.md +## File: docs/extending/block-registration.md ````markdown -# Create an Airtable remote data block +# Block registration -This tutorial will walk you through connecting an [Airtable](https://airtable.com/) data source and how to use the automatically created block in the WordPress editor. +Use the `register_remote_data_block` function to register your remote data block and associate it with your query and data source. This example: -## Base and personal access token +1. Creates a [data source](data-source.md). +2. Associates the data source with a [query](query.md). +3. Defines the output schema of a query, which tells the plugin how to map the query response to blocks. +4. Registers a remote data block. -First, identify an Airtable base and table that you want to use as a data source. This example uses a base created from the default [“Event planning” template](https://www.airtable.com/templates/event-planning/exppdJtYjEgfmd6Sq), accessible from the Airtable home screen after logging in. We will target the “Schedule” table from that base. +We are assuming `https://api.example.com/` returns JSON that has a shape like: -

airtable-template

+```json +{ + "id": 12345, + "title": "An awesome title" +} +``` -Next, [create a personal access token](https://airtable.com/create/tokens) that has the `data.records:read` and `schema.bases:read` scopes and has access to the base or bases you wish to use. +```php +function register_your_custom_block() { + $data_source = [ + 'display_name' => 'Example API', + 'endpoint' => 'https://api.example.com/', + ]; -

create-pat

+ $render_query = [ + 'display_name' => 'Example Query', + 'data_source' => $data_source, + 'output_schema' => [ + 'type' => [ + 'id' => [ + 'name' => 'ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'path' => '$.title', + 'type' => 'string', + ], + ], + ], + ]; -You should not commit this token directly to your code or share it publicly. The Remote Data Blocks plugin stores the token in the WordPress database. + register_remote_data_block( [ + 'title' => 'My Block', + 'render_query' => [ + 'query' => $render_query, + ], + ] ); +} +add_action( 'init', 'register_your_custom_block', 10, 0 ); +``` -## Create the data source +## Configuration options -1. Go to Settings > Remote Data Blocks in your WordPress admin. -2. Click on the "Connect new" button. -3. Choose "Airtable" from the dropdown menu as the data source type. -4. Name this data source. This name is only used for display purposes. -5. Enter the access token you created in Airtable. +### `title`: string (required) -If the personal access token is correct, you will be able to proceed to the other steps. If you receive an error, check the token and try again. +The human-friendly name of the block. It is also used to construct the block's name; a title of "My Block" will result in a block name of `remote-data-blocks/my-block`. -6. Select your desired base and tables. -7. Save the data source and return the data source list. +### `render_query`: array (required) -## Insert the block +The render query is executed when the block is rendered and fetches the data that will be provided to block bindings. It is an array with the following properties: -Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. +- `query` (required): An instance of [`QueryInterface`](./query.md) that fetches the data. - +### `selection_queries`: array (optional) -## Patterns and styling +Selection queries are used by content creators to select or curate remote data in the block editor. For example, you may wish to provide a list of products to users and allow them to select one to include in their post, or you may want to allow a user to search for a specific item. Selection queries are an array of objects with the following properties: -You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). +- `display_name`: A human-friendly name for the selection query. +- `query` (required): An instance of `QueryInterface` that fetches the data. +- `type`: A string that determines the type of selection query. Accepted values are currently `list` or `search`. -Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. +Example: -## Code reference +```php +'selection_queries' => [ + [ + 'display_name' => 'Select a product', + 'query' => $list_products_query, + 'type' => 'list', + ], + [ + 'display_name' => 'Search for a product', + 'query' => $search_products_query, + 'type' => 'search', + ], +], +``` -You can also configure Airtable integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. +#### Search queries -This [example template](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/airtable-block) will replicate what we've done in this tutorial. -```` +Search queries must return a collection and must accept an input variable with the special type `ui:search_input`. The [Art block](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/art-block/art-block.php) example looks like this: -## File: docs/tutorials/google-sheets.md -````markdown -# Create a Google Sheets remote data block +```php +$search_art_query = [ + 'data_source' => $aic_data_source, + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $query = $input_variables['search']; + $endpoint = $aic_data_source->get_endpoint() . '/search'; -This tutorial will walk you through connecting a [Google Sheets](https://workspace.google.com/products/sheets/) data source and how to use the automatically created block in the WordPress editor. + return add_query_arg( [ 'q' => $query ], $endpoint ); + }, + 'input_schema' => [ + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'string', + ], + ], + ], +]; +``` -## Google Sheets API Access +Here you can see the `search` input variable has a special type of `ui:search_input` and is used in the endpoint method to populate a query string. You can read more about [queries](./query.md) and how to construct them. End users enter the search term to find the specific item. -Google Sheets API access is required to connect to Google Sheets. The plugin uses a [service account](https://cloud.google.com/iam/docs/service-account-overview?hl=en) to authenticate requests to the Google Sheets API. The following steps are required to set up Google Sheets API access: +![Screenshot showing the search input in the WordPress Editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/search-input.png) -- [Create a project](https://developers.google.com/workspace/guides/create-project) in Google Cloud Platform. `resourcemanager.projects.create` permission is needed to create a new project. You can skip this step if you already have a project available in your organization via the Google Cloud Platform. -- Enable the Google [Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com) and [Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) (required for listing spreadsheets) for your project. You can access these from the links above or by clicking "Enabled APIs & services" in the left-hand menu and then "+ ENABLE APIS AND SERVICES" at the top center of the screen. -- [Create a service account](https://cloud.google.com/iam/docs/service-accounts-create), which will be used to authenticate the requests to the Google Sheets API. You will need to enable the IAM API first, and then if you scroll down further on the page linked above, you can click the button to "Go to Create service account." -- Select the "Owner" role and note the service account email address. -- You will need to create the JSON key for this account. You can access the key by clicking on the three dots under Actions in the Service account table and choosing "Manage Keys." - ![Screenshot showing a portion of the Google Console](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/google-console.png) -- Click on "Add Key" and choose the JSON type. The file will be automatically downloaded. Keep this file safe, as it will be used to authenticate the block. -- Grant access to the service account email to the Google Sheet. The service account will authenticate the requests to the Google Sheets API for the given sheet. +**Note:** The same search box appears for `list` query types. For this type, the form is only filtering the results returned by the initial list query. For `search` queries, an additional query is made for every search. -## Setting up the Google Sheet +### `overrides`: array (optional) -- Identify the Google Sheet that you want to connect to. -- Share the Google Sheet with the service account email address you noted above. Viewer access is sufficient. -- Note down the Google Sheet ID from the URL. For example, in the URL `https://docs.google.com/spreadsheets/d/test_spreadsheet_id/edit?gid=0#gid=0`, the Google Sheet ID is `test_spreadsheet_id`. The Google Sheet ID is the unique identifier for the Google Sheet. +[Overrides](overrides.md) are used to customize the behavior of the block on a per-block basis. -## Create the data source +### `patterns`: array (optional) -1. Go to Settings > Remote Data Blocks in your WordPress admin. -2. Click on the "Connect new" button. -3. Choose "Google Sheets" from the dropdown menu as the data source type. -4. Name this data source (this name is only used internally). -5. Enter the contents of the JSON file you downloaded. +[Block patterns](block-patterns.md) allow you to customize the display of your remote data. +```` -If the credentials are correct, you will be able to proceed to the other steps. If you receive an error, check the token and try again. +## File: docs/extending/index.md +````markdown +# Extending -6. Select your desired spreadsheet and sheets. -7. Save the data source and return the data source list. +> [!TIP] +> Make sure you've read the [core concepts](../concepts/index.md) behind Remote Data Blocks before extending the plugin. -## Insert the block +Data sources and queries can be configured in the plugin UI but, sometimes, you need to write code to implement custom functionality or connect with data sources that aren't fully supported. Remote Data Blocks provides flexible configuration, extendable classes, hooks, and filters to help you connect to any remote data source and customize the output. -Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. You will notice both a loop and a single block are available. +## Customization -The loop block will return all the entries in the spreadsheet. +Defining a data source or query in code gives you complete control over how data is fetched, processed, and rendered. In the case of unsupported APIs, it's a necessary step to define the schema and logic for fetching data. -## Patterns and styling +- [Data source](data-source.md) +- [Query](query.md) +- [Block registration](block-registration.md) -You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). +## Advanced customization -Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. +- [Block patterns](block-patterns.md) +- [Hooks (actions and filters)](hooks.md) +- [Overrides](overrides.md) -## Code reference +## Examples and AI prompts -You can also configure Google Sheets integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. +The included [examples](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/README.md) provide detailed code samples and templates. -This [example template](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/google-sheets-block) will replicate what we've done in this tutorial. -```` +For quick development, we highly recommend [leveraging AI](ai-prompts.md) to scaffold and iterate on new integrations. -## File: docs/tutorials/index.md -````markdown -# Tutorials +## Local development environment -This section will guide you through configuring data sources in the plugin settings and via code. +This repository includes tools for quickly starting a [local development environment](../local-development.md). -- [Airtable](airtable.md) -- [Google Sheets integration](google-sheets.md) -- [Shopify](shopify.md) -- [HTTP](http.md) -```` +## Data Flow -## File: docs/tutorials/shopify.md -````markdown -# Create a Shopify remote data block +Here's a short overview of how data flows through the plugin when a post with a remote data block is rendered: -This tutorial will walk you through connecting a [Shopify](https://www.shopify.com/) data source and how to use the automatically created block in the WordPress editor. +1. WordPress core loads the post content, parses the blocks, and recognizes that a paragraph block has a [block binding](../concepts/block-bindings.md). +2. WordPress core calls the block binding callback function: `BlockBindings::get_value()`. +3. The callback function inspects the paragraph block. Using the block context supplied by the parent remote data block, it determines which [query](query.md) to execute. +4. The query is executed: `$query->execute()`. +5. Various properties of the query are requested by the query runner, including the endpoint, request headers, request method, and request body. Some of these properties are delegated to the data source (`$query->get_data_source()`). +6. The query is dispatched, and the response data is inspected, formatted into a consistent shape, and returned to the block binding callback function. +7. The callback function extracts the requested field from the response data and returns it to WordPress core for rendering. +```` -## Shopify API Access +## File: docs/extending/overrides.md +````markdown +# Overrides -To use the Shopify data source, you need to have an access token. You can create one by following these steps: +Overrides provide a way to customize the behavior of remote data blocks on a per-block basis. You can use them to modify the underlying query input variables, adjust the query response, or change the caching behavior. Overrides are defined when you register a remote data block and can be enabled or disabled via the block settings in the WordPress editor. -1. Login to your Shopify admin account. -2. Click "Apps" in the left sidebar. -3. Click "Apps and sales channels" in the dropdown menu. -4. Click "Develop apps". -5. Click "Create an app". -6. Give the app a name and click "Create app". -7. Give the app `unauthenticated_read_product_listings` permissions and click "Install". -8. Copy the access token from the "API Credentials" section. +If you have multiple instances of the same remote data block in a piece of content, each instance can have different overrides enabled. By default, no overrides are enabled. -## Create the data source +Here is an example of an override that modifies the query input variables based on the URL. -1. Go to Settings > Remote Data Blocks in your WordPress admin. -2. Click on the "Connect new" button. -3. Choose "Shopify" from the dropdown menu as the data source type. -4. Name the data source. This name is only used for display purposes. -5. Enter the subdomain of your Shopify store. To find this, log into Shopify, the subdomain of your store is the portion of the URL before `myshopify.com`. -6. Enter your access token. +You could use this to build a "product page" in the WordPress admin that would be able to display any product, using an ID from the URL, e.g.: https://example.com/product/123456 -If the credentials are correct, you can save the data source. If you receive an error, check the token and try again. +The example takes advantage of the [`add_rewrite_rule`](https://developer.wordpress.org/reference/functions/add_rewrite_rule/) function and the [`query_vars`](https://developer.wordpress.org/reference/hooks/query_vars/) filter that are built into WordPress. -## Insert the block +```php +register_remote_data_block( [ + 'title' => 'Acme Product', + 'render_query' => [ + 'query' => $get_product_query, + ], + 'overrides' => [ + [ + 'name' => 'product_id_override', + 'display_name' => __( 'Use product ID from URL', 'my-text-domain' ), + 'help_text' => __( 'For use on the /products/ page', 'my-text-domain' ), + ], + ], +] ); -Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. +add_rewrite_rule( '^products/([0-9]+)/?', 'index.php?pagename=products&acme_product_id=$matches[1]', 'top' ); -![How inserting a Shopify block looks in the WordPress Editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/insert-shopify-block.gif) +add_filter( 'query_vars', function ( array $query_vars ): array { + $query_vars[] = 'acme_product_id'; + return $query_vars; +}, 10, 1 ); -## Patterns and styling +add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides ): array { + if ( true === in_array( 'product_id_override', $enabled_overrides, true ) ) { + $product_id = get_query_var( 'acme_product_id' ); -You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). + if ( ! empty( $product_id ) ) { + $input_variables['product_id'] = $product_id; + } + } -Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. + return $input_variables; +}, 10, 2 ); +``` -## Code reference +As you can see, the `remote_data_blocks_query_input_variables` filter is passed a list of enabled overrides. You need to add logic to identify which filters are enabled and act accordingly. -You can also configure Shopify integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. +The `overrides` property in the block registration array enables a panel in the block settings that allows content authors to enable or disable the override: -This [working example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/shopify-product-block) will replicate what we've done in this tutorial. +An overrides panel in a remote data block settings panel ```` -## File: docs/local-development.md +## File: docs/extending/query-input-schema.md ````markdown -# Local Development - -This repository includes tools for starting a local development environment using [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/), which requires Docker and Docker Compose. In addition, both `npm` and `composer` are required to install the local dependencies. - -## Set up +# HttpQuery `input_schema` property -Clone this repository and install its dependencies:. +The `input_schema` property defines the input variables expected by the query. The property should be an associative array of input variable definitions. The keys of the array are machine-friendly input variable names, and the values are associative arrays with the following structure: -```sh -npm install -``` +- `name` (optional): The human-friendly display name of the input variable +- `default_value` (optional): The default value for the input variable. +- `type` (required): The primitive type of the input variable. Supported types are: + - `boolean` + - `id` + - `integer` + - `null` + - `number` + - `string` -To start a development environment with Xdebug enabled: +#### Example -```sh -npm run dev +```php +'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], +], ``` -This will spin up a WordPress environment and a Valkey (Redis) instance for object cache. It will also build the block editor scripts, watch for changes, and open a Node.js debugging port. The WordPress environment will be available at `http://localhost:8888` (admin user: `admin`, password: `password`). - -Stop the development environment with `Ctrl+C` and resume it by running the same command. You can also manually stop the environment with `npm run dev:stop`. Stopping the environment optionally stops the WordPress containers but preserves their state. +There are also some special input variable types: -### Sharing configuration +- `ui:search_input`: A variable with this type indicates that the query supports searching. It must accept a `string` containing search terms. +- `ui:pagination_offset`: A variable with this type indicates that the query supports offset pagination. It must accept an `integer` containing the requested offset. See `pagination_schema` for additional information and requirements. +- `ui:pagination_page`: A variable with this type indicates that the query supports page-based pagination. It must accept an `integer` containing the requested results page. See `pagination_schema` for additional information and requirements. +- `ui:pagination_per_page`: A variable with this type indicates that the query supports controlling the number of resultsper page. It must accept an `integer` containing the number of requested results. +- `ui:pagination_cursor_next` and `ui_pagination_cursor_previous`: Variables with these types indicate that the query supports cursor pagination. They accept `string`s containing the requested cursor. See `pagination_schema` for additional information and requirements. +- `ui:pagination_cursor`: A variable with this type indicates support for a simple variant of cursor pagination that uses a single cursor instead of a pair of forward / backward cursors. It accepts a `string` containing the requested cursor. See `pagination_schema` for additional information and requirements. -Data sources configured via the Remote Data Blocks WordPress Admin UI are encrypted and stored as `remote_data_blocks_configs` in the Options table of the WordPress database. +#### Example with search and pagination input variables -If your local and production environments do not use the same encryption secrets, your configuration from one environment will not work in the other. Keep this in mind when migrating the database between environments. +```php +'input_schema' => [ + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Pagination limit', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Pagination page', + 'type' => 'ui:pagination_page', + ], +], +``` -### Testing +If omitted, `input_schema` defaults to an empty array. +```` -Run unit tests: +## File: docs/extending/query-output-schema.md +````markdown +# HttpQuery `output_schema` property -```sh -# all unit tests -npm run test +A query's `output_schema` defines how an API response should be transformed and provided to a remote data block. A typical goal is to transform the API response into a flat array of fields that can be bound to blocks, while omitting values that are not needed. Output can be nested, but nested values cannot be bound to blocks. -# only JavaScript unit tests -npm run test:js +Note that the output schema may require updates whenever the shape or schema of the API response changes. Similarly, changing the slug or `type` of a field may break existing bindings. Consider creating a new query and remote data block if you need to make breaking changes to an output schema. -# only PHP unit tests -npm run test:php +## Properties -# only a specific test file -npm run test:js some/test/file.js -npm run test:php -- --filter SomeTestClass -``` +- `format` (optional): A callable function that formats the output variable value. +- `generate` (optional): A callable function that generates or extracts the output variable value from the response, as an alternative to `path`. It receives two parameters: + - `array $data`: The data returned by the API, which is contains the data returned from the API at the current "level" (e.g., after the root `path` has been applied, if present). + - `array $raw_response_data`: The "raw" response data returned by the API, which includes the input variables (`$raw_response_data['input_variables']`), response metadata (`$raw_response_data['metadata']`), and the entire API response before any preprocessing. +- `is_collection` (optional, default `false`): A boolean indicating whether the response data is a collection. If false, only a single item will be returned. +- `name` (optional): The human-friendly display name of the output variable. +- `default_value` (optional): The default value for the output variable. +- `path` (optional): A [JSONPath](https://jsonpath.com/) expression to extract the variable value from the response. Note that path expressions are relative to the current item and its type; path expressions therefore "build" on each other when you nest types. +- `type` (required): A primitive type (e.g., `string`, `boolean`) or a nested output schema. -For e2e tests, ensure the development environment is running, then execute: +Accepted primitive types are: -```sh -npm run test:e2e -``` +- `boolean` +- `button_url` +- `email_address` +- `html` +- `id` +- `image_alt` +- `image_url` +- `integer` +- `markdown` +- `null` +- `number` +- `string` +- `url` +- `uuid` -### Logs +## Single entity example -Watch logs from the WordPress container: +Using the [Zip Code block](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/zip-code-block/zip-code-block.php), the JSON response returned by the API looks like this: -```sh -npx wp-env logs +```json +{ + "post code": "17057", + "country": "United States", + "country abbreviation": "US", + "places": [ + { + "place name": "Middletown", + "longitude": "-76.7331", + "state": "Pennsylvania", + "state abbreviation": "PA", + "latitude": "40.2041" + } + ] +} ``` -### WP-CLI +And the corresponding `output_schema` definition might look like this: + +```php +'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', + 'type' => 'string', + ], + 'city_state' => [ + 'name' => 'City, State', + 'default_value' => 'Unknown', + 'generate' => function( array $data, array $raw_response_data ): string|null { + if ( empty( $data['places'] ) ) { + return null; + } + + return $data['places'][0]['place name'] . ', ' . $data['places'][0]['state abbreviation']; + }, + 'type' => 'string', + ], + ], +], +``` + +- The `is_collection` property indicates whether the output represents a single entity or a collection of entities. In this case, it is set to `false` because the API returns a single entity. +- The `type` property at the root level begins the type definition. The `zip_code` and `city_state` array keys are "slugs" that identify the field. The array values define types that describe how to extract a value for those fields. +- The `zip_code` field is extracted via a [JSONPath](http://jsonpath.com) expression defined in the `path` property. +- The `city_state` field provides a callable via the `generate` property. That function receives the response data and combines two elements to form the value. +- A `default_value` property provides a value that will be used if the provided `path` expression or `generate` function resolve to a null value. + +The result of applying this output schema to the example JSON response is: + +```php +[ + zip_code => '17057', + city_state => 'Middletown, PA', +] +``` + +## Collection example + +An example of collection JSON can be found in the [Art block example](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/art-block/art-block.php). That API returns (in part): + +```json +{ + "preference": null, + "pagination": { + "total": 183, + "limit": 10, + "offset": 0, + "total_pages": 19, + "current_page": 1 + }, + "data": [ + { + "_score": 155.49371, + "thumbnail": { + "alt_text": "Color pastel drawing of ballerinas in tutus on stage, watched by audience.", + "width": 3000, + "lqip": "data:image/gif;base64,R0lGODlhCgAFAPUAADtMRVJPRFlOQlBNSFFNSEVURU1USldSS1dSTVRXTV9ZTldVUl1ZU2hbTVdkU19kVV5tX2FkUGFjVWVoVGhoVGZhW29lXGVtXG1rWmlpXW5tXmZxX3VxX1toZG5oYG5uZ3ZsY3BqZGN1a3RxYnFyZXRxZntxan19bnl9cnh7dX57doJ/dpGEeJKOhaCUjKebk6yflsGupQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAAAKAAUAAAYuQIjoQuGQTqhOyrEZYSQJA6AweURYrxIoxAhoMp9VywWLmRYqj6BxQFQshIEiCAA7", + "height": 1502 + }, + "api_model": "artworks", + "is_boosted": true, + "api_link": "https://api.artic.edu/api/v1/artworks/61603", + "id": 61603, + "title": "Ballet at the Paris Opéra", + "timestamp": "2025-01-14T22:26:21-06:00" + }, + { + "_score": 152.35487, + "thumbnail": { + "alt_text": "Impressionist painting of woman wearing green dress trying on hats.", + "width": 5003, + "lqip": "data:image/gif;base64,R0lGODlhBgAFAPQAAEMtIk40KE83KlhHLVxELlNPN1hLMVJOP19UN1dYM1lUOVpUP2dAIWlKKHZKKXZLKWRNPGpbMGpaNGtaOkxUTF9dRlJaS15YSV5kUnZpRH12W4ZkM49uRI52VQAAAAAAACH5BAAAAAAALAAAAAAGAAUAAAUY4AUtFWZxHZIdExFEybAJQGE00sNQmqOEADs=", + "height": 4543 + }, + "api_model": "artworks", + "is_boosted": true, + "api_link": "https://api.artic.edu/api/v1/artworks/14572", + "id": 14572, + "title": "The Millinery Shop", + "timestamp": "2025-01-14T23:26:12-06:00" + } + ], + "info": { + "license_text": "The `description` field in this response is licensed under a Creative Commons Attribution 4.0 Generic License (CC-By) and the Terms and Conditions of artic.edu. All other data in this response is licensed under a Creative Commons Zero (CC0) 1.0 designation and the Terms and Conditions of artic.edu.", + "license_links": [ + "https://creativecommons.org/publicdomain/zero/1.0/", + "https://www.artic.edu/terms" + ], + "version": "1.10" + }, + "config": { + "iiif_url": "https://www.artic.edu/iiif/2", + "website_url": "http://www.artic.edu" + } +} +``` + +An output schema can be defined as: + +```php +'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Art Title', + 'type' => 'string', + ], + ], +], +``` + +- The `is_collection` property is set to `true` to indicate that the output represents a collection of entities. +- A top-level `path` expression (`$.data[*]`) indicates that the collection is contained in the `data` property of the response. +- The `type` property defines two fields: `id` and `title`. + - Note that the nested type definitions do not provide a `path` expression. When omitted, the plugin will use the slug as the expected path. This is a shorthand for the following output schema with explicit `path` expressions: + +```php +'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Art Title', + 'path' => '$.title', + 'type' => 'string', + ], + ], +], +``` + +We can enhance the output schema with additional fields and options: + +```php +'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Art Title', + 'format' => function ( string $value ): string { + return ucfirst( $value ); + }, + 'type' => 'string', + ], + 'thumbnail_image_alt' => [ + 'name' => 'Thumbnail alt text', + 'path' => '$.thumbnail.alt_text', + 'type' => 'image_alt', + ], + 'thumbnail_image_url' => [ + 'name' => 'Thumbnail', + 'path' => '$.thumbnail.lqip', + 'type' => 'image_url', + ], + ], +], +``` + +The `format` property allows you to define a callable that will be applied to the value before it is returned. + +Applying this output schema to the response JSON would result in the following output: + +```php +[ + [ + 'id' => 61603, + 'title' => 'Ballet at the Paris Opéra', + 'thumbnail_image_alt' => 'Color pastel drawing of ballerinas in tutus on stage, watched by audience.', + 'thumbnail_image_url' => 'data:image/gif;base64,R0lGODlhCgAFAPUAADtMRVJPRFlOQlBNSFFNSEVURU1USldSS1dSTVRXTV9ZTldVUl1ZU2hbTVdkU19kVV5tX2FkUGFjVWVoVGhoVGZhW29lXGVtXG1rWmlpXW5tXmZxX3VxX1toZG5oYG5uZ3ZsY3BqZGN1a3RxYnFyZXRxZntxan19bnl9cnh7dX57doJ/dpGEeJKOhaCUjKebk6yflsGupQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAAAKAAUAAAYuQIjoQuGQTqhOyrEZYSQJA6AweURYrxIoxAhoMp9VywWLmRYqj6BxQFQshIEiCAA7', + ], + [ + 'id' => 14572, + 'title' => 'The Millinery Shop', + 'thumbnail_image_alt' => 'Impressionist painting of woman wearing green dress trying on hats.', + 'thumbnail_image_url' => 'data:image/gif;base64,R0lGODlhBgAFAPQAAEMtIk40KE83KlhHLVxELlNPN1hLMVJOP19UN1dYM1lUOVpUP2dAIWlKKHZKKXZLKWRNPGpbMGpaNGtaOkxUTF9dRlJaS15YSV5kUnZpRH12W4ZkM49uRI52VQAAAAAAACH5BAAAAAAALAAAAAAGAAUAAAUY4AUtFWZxHZIdExFEybAJQGE00sNQmqOEADs=', + ], +] +``` +```` + +## File: docs/tutorials/airtable.md +````markdown +# Create an Airtable remote data block + +This tutorial will walk you through connecting an [Airtable](https://airtable.com/) data source and how to use the automatically created block in the WordPress editor. + +## Base and personal access token + +First, identify an Airtable base and table that you want to use as a data source. This example uses a base created from the default [“Event planning” template](https://www.airtable.com/templates/event-planning/exppdJtYjEgfmd6Sq), accessible from the Airtable home screen after logging in. We will target the “Schedule” table from that base. + +

airtable-template

+ +Next, [create a personal access token](https://airtable.com/create/tokens) that has the `data.records:read` and `schema.bases:read` scopes and has access to the base or bases you wish to use. + +

create-pat

+ +You should not commit this token directly to your code or share it publicly. The Remote Data Blocks plugin stores the token in the WordPress database. + +## Create the data source + +1. Go to Settings > Remote Data Blocks in your WordPress admin. +2. Click on the "Connect new" button. +3. Choose "Airtable" from the dropdown menu as the data source type. +4. Name this data source. This name is only used for display purposes. +5. Enter the access token you created in Airtable. + +If the personal access token is correct, you will be able to proceed to the other steps. If you receive an error, check the token and try again. + +6. Select your desired base and tables. +7. Save the data source and return the data source list. + +## Insert the block + +Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. + + + +## Patterns and styling + +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). + +Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. + +## Code reference + +You can also configure Airtable integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. + +This [example template](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/airtable-block) will replicate what we've done in this tutorial. +```` + +## File: docs/tutorials/google-sheets.md +````markdown +# Create a Google Sheets remote data block + +This tutorial will walk you through connecting a [Google Sheets](https://workspace.google.com/products/sheets/) data source and how to use the automatically created block in the WordPress editor. + +## Google Sheets API Access + +Google Sheets API access is required to connect to Google Sheets. The plugin uses a [service account](https://cloud.google.com/iam/docs/service-account-overview?hl=en) to authenticate requests to the Google Sheets API. The following steps are required to set up Google Sheets API access: + +- [Create a project](https://developers.google.com/workspace/guides/create-project) in Google Cloud Platform. `resourcemanager.projects.create` permission is needed to create a new project. You can skip this step if you already have a project available in your organization via the Google Cloud Platform. +- Enable the Google [Sheets API](https://console.cloud.google.com/apis/library/sheets.googleapis.com) and [Drive API](https://console.cloud.google.com/apis/library/drive.googleapis.com) (required for listing spreadsheets) for your project. You can access these from the links above or by clicking "Enabled APIs & services" in the left-hand menu and then "+ ENABLE APIS AND SERVICES" at the top center of the screen. +- [Create a service account](https://cloud.google.com/iam/docs/service-accounts-create), which will be used to authenticate the requests to the Google Sheets API. You will need to enable the IAM API first, and then if you scroll down further on the page linked above, you can click the button to "Go to Create service account." +- Select the "Owner" role and note the service account email address. +- You will need to create the JSON key for this account. You can access the key by clicking on the three dots under Actions in the Service account table and choosing "Manage Keys." + ![Screenshot showing a portion of the Google Console](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/google-console.png) +- Click on "Add Key" and choose the JSON type. The file will be automatically downloaded. Keep this file safe, as it will be used to authenticate the block. +- Grant access to the service account email to the Google Sheet. The service account will authenticate the requests to the Google Sheets API for the given sheet. + +## Setting up the Google Sheet + +- Identify the Google Sheet that you want to connect to. +- Share the Google Sheet with the service account email address you noted above. Viewer access is sufficient. +- Note down the Google Sheet ID from the URL. For example, in the URL `https://docs.google.com/spreadsheets/d/test_spreadsheet_id/edit?gid=0#gid=0`, the Google Sheet ID is `test_spreadsheet_id`. The Google Sheet ID is the unique identifier for the Google Sheet. + +## Create the data source + +1. Go to Settings > Remote Data Blocks in your WordPress admin. +2. Click on the "Connect new" button. +3. Choose "Google Sheets" from the dropdown menu as the data source type. +4. Name this data source (this name is only used internally). +5. Enter the contents of the JSON file you downloaded. + +If the credentials are correct, you will be able to proceed to the other steps. If you receive an error, check the token and try again. + +6. Select your desired spreadsheet and sheets. +7. Save the data source and return the data source list. + +## Insert the block + +Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. You will notice both a loop and a single block are available. + +The loop block will return all the entries in the spreadsheet. + +## Patterns and styling + +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). + +Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. + +## Code reference + +You can also configure Google Sheets integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. + +This [example template](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/google-sheets-block) will replicate what we've done in this tutorial. +```` + +## File: docs/tutorials/index.md +````markdown +# Tutorials + +This section will guide you through configuring data sources in the plugin settings and via code. + +- [Airtable](airtable.md) +- [Google Sheets integration](google-sheets.md) +- [Shopify](shopify.md) +- [HTTP](http.md) +```` + +## File: docs/local-development.md +````markdown +# Local Development + +This repository includes tools for starting a local development environment using [`@wordpress/env`](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-env/), which requires Docker and Docker Compose. In addition, both `npm` and `composer` are required to install the local dependencies. + +## Set up + +Clone this repository and install its dependencies:. + +```sh +npm install +``` + +To start a development environment with Xdebug enabled: + +```sh +npm run dev +``` + +This will spin up a WordPress environment and a Valkey (Redis) instance for object cache. It will also build the block editor scripts, watch for changes, and open a Node.js debugging port. The WordPress environment will be available at `http://localhost:8888` (admin user: `admin`, password: `password`). + +Stop the development environment with `Ctrl+C` and resume it by running the same command. You can also manually stop the environment with `npm run dev:stop`. Stopping the environment optionally stops the WordPress containers but preserves their state. + +### Sharing configuration + +Data sources configured via the Remote Data Blocks WordPress Admin UI are encrypted and stored as `remote_data_blocks_configs` in the Options table of the WordPress database. + +If your local and production environments do not use the same encryption secrets, your configuration from one environment will not work in the other. Keep this in mind when migrating the database between environments. + +### Testing + +Run unit tests: + +```sh +# all unit tests +npm run test + +# only JavaScript unit tests +npm run test:js + +# only PHP unit tests +npm run test:php + +# only a specific test file +npm run test:js some/test/file.js +npm run test:php -- --filter SomeTestClass +``` + +For e2e tests, ensure the development environment is running, then execute: + +```sh +npm run test:e2e +``` + +### Logs + +Watch logs from the WordPress container: + +```sh +npx wp-env logs +``` + +### WP-CLI Run WP-CLI commands: @@ -493,6 +1018,55 @@ npm run playground Playgrounds do not closely mirror production environments and are missing persistent object cache, debugging tools, and other important features. Use `npm run dev` for local development. ```` +## File: docs/troubleshooting.md +````markdown +# Troubleshooting and debugging + +This plugin provides a [local development environment](local-development.md) with built-in debugging tools. + +## Query monitor + +When the [Query Monitor plugin](https://wordpress.org/plugins/query-monitor/) is installed and activated, Remote Data Blocks will output debugging information to a dedicated "Remote Data Blocks" panel, including error details, stack traces, query execution details, and cache hit/miss status. + +> [!TIP] +> By default, the block editor is rendered in "Fullscreen mode" which hides the Admin Bar and Query Monitor. Open the three-dot menu in the top-right corner and toggle off "Fullscreen mode", or press `⇧⌥⌘F`. + +The provided local development environment includes Query Monitor by default. You can also install it in non-local environments, but be aware that it may expose sensitive information in production environments. Query Monitor is currently not compatible with WordPress Playground and cannot be installed there. + +## Debugging + +The [local development environment](local-development.md) includes Xdebug for debugging PHP code and a Node.js debugging port for debugging block editor scripts. + +## Support + +Our goal is to ensure that Remote Data Blocks works with as many APIs as possible. While we cannot guarantee that we can support every API, we are happy to receive detailed reports of any issues you encounter. Please [create a GitHub issue using the "API integration issue" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=api_integration_issue.md) and we will do our best to assist you. + +For general bugs, please [use the "General bug report" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=bug_report.md). If you have feedback or suggestions for improvement, please [use the "Feedback" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=general_feedback.md). + +## Resetting config + +If you need to reset the Remote Data Blocks configuration in your local development environment, you can use WP-CLI to delete the configuration option. This will permanently delete all configuration values, including access tokens and API keys. + +```sh +npm run wp-cli option delete remote_data_blocks_config +``` +```` + +## File: example/.cursor/rules/project-scope.mdc +```` +--- +description: Project scope +globs: +alwaysApply: true +--- + +- You are writing code that integrates with the Remote Data Blocks WordPress plugin. This plugin allows you to create Gutenberg blocks that display data from remote data sources, such as Airtable, Google Sheets, Shopify, or your own API. +- You are not contributing to the plugin directly. You are writing code that will be used in a separate plugin or theme. +- You do not need to develop custom Gutenberg blocks. Instead, you will write simple PHP code to describe how your API should be queried, then call registration functions provided by the Remote Data Blocks plugin. +- Your goal is to configure and register a remote data block that displays remote data in an organized, visually appealing way. +- The Remote Data Blocks plugin provides a default block pattern for displaying data, but it is very basic. You may need to create a custom block pattern to achieve your goal, but please ask before doing so. +```` + ## File: example/assets/blueprint-content.wxr ```` @@ -864,50 +1438,177 @@ add_action( 'init', __NAMESPACE__ . '\\register_open_library_remote_data_block' ```` -## File: example/blocks/github-markdown-block/inc/github-query-runner.php -````php -ensure_file_extension( $input_variables['file_path'] ); + + return parent::execute( $query, $input_variables ); + } + + /** + * @inheritDoc + * + * The API response is raw HTML, so we return an object construct containing + * the HTML as a property. + */ + protected function deserialize_response( string $raw_response_data, array $input_variables ): array { + return [ + 'content' => $raw_response_data, + 'path' => $input_variables['file_path'], + ]; + } + + private function ensure_file_extension( string $file_path ): string { + return str_ends_with( $file_path, $this->default_file_extension ) ? $file_path : $file_path . $this->default_file_extension; + } +} +```` + +## File: example/blocks/github-markdown-block/inc/markdown-links.php +````php +' . $html; + + // Suppress errors due to malformed HTML + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); + + // Create an XPath to query href attributes + $xpath = new DOMXPath( $dom ); + + // Query all elements with href attributes + $nodes = $xpath->query( '//*[@href]' ); + foreach ( $nodes as $node ) { + if ( ! $node instanceof DOMElement ) { + continue; + } + $href = $node->getAttribute( 'href' ); + + // Check if the href is non-empty, points to a markdown file, and is a local path + if ( $href && + preg_match( '/\.md($|#)/', $href ) && + ! preg_match( '/^(https?:)?\/\//', $href ) + ) { + // Adjust the path + $new_href = adjust_markdown_file_path( $href, $current_file_path ); -namespace RemoteDataBlocks\Example\GitHub; + // Set the new href + $node->setAttribute( 'href', $new_href ); + } + } -use RemoteDataBlocks\Config\Query\HttpQueryInterface; -use RemoteDataBlocks\Config\QueryRunner\QueryRunner; -use WP_Error; + // Remove the data attributes that GitHub uses for click-to-copy functionality. + // The DOM parser is unable to keep them encoded correctly. + $click_to_copy_attribute = 'data-snippet-clipboard-copy-content'; + $nodes = $xpath->query( sprintf( '//*[@%s]', $click_to_copy_attribute ) ); + foreach ( $nodes as $node ) { + if ( ! $node instanceof DOMElement ) { + continue; + } + $node->removeAttribute( $click_to_copy_attribute ); + } + + // Save and return the updated HTML without the XML declaration. + return preg_replace( '/^<\?xml[^>]+\?>/', '', $dom->saveHTML() ); +} -defined( 'ABSPATH' ) || exit(); /** - * Custom query runner that process custom processing for GitHub API responses - * that return HTML / Markdown instead of JSON. This also provides custom - * processing to adjust embedded links. + * Adjusts the markdown file path by resolving relative paths to absolute paths. + * Preserves fragment identifiers (anchors) in the URL. * - * Data fetching and caching is still delegated to the parent QueryRunner class. + * @param string $path The original path. + * @param string $current_file_path The current file's path. + * @return string The adjusted path. */ -class GitHubQueryRunner extends QueryRunner { - private string $default_file_extension = '.md'; +function adjust_markdown_file_path( string $path, string $current_file_path = '' ): string { + global $post; + $page_slug = $post->post_name; - public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { - $input_variables['file_path'] = $this->ensure_file_extension( $input_variables['file_path'] ); + // Parse the URL to separate the path and fragment + $parts = wp_parse_url( $path ); - return parent::execute( $query, $input_variables ); - } + // Extract the path and fragment + $original_path = isset( $parts['path'] ) ? $parts['path'] : ''; + $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; - /** - * @inheritDoc - * - * The API response is raw HTML, so we return an object construct containing - * the HTML as a property. - */ - protected function deserialize_response( string $raw_response_data, array $input_variables ): array { - return [ - 'content' => $raw_response_data, - 'path' => $input_variables['file_path'], - ]; - } + // Get the directory of the current file + $current_dir = dirname( $current_file_path ); - private function ensure_file_extension( string $file_path ): string { - return str_ends_with( $file_path, $this->default_file_extension ) ? $file_path : $file_path . $this->default_file_extension; + // Resolve the absolute path based on the current directory + if ( str_starts_with( $original_path, '/' ) ) { + // Already an absolute path from root, just remove leading slash + $absolute_path = ltrim( $original_path, '/' ); + } else { + // Use realpath to resolve relative paths + $temp_path = $current_dir . '/' . $original_path; + $parts = explode( '/', $temp_path ); + $absolute_parts = []; + + foreach ( $parts as $part ) { + if ( '.' === $part || '' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $absolute_parts ); + } else { + $absolute_parts[] = $part; + } + } + + $absolute_path = implode( '/', $absolute_parts ); } + + // Remove the .md extension + $absolute_path = preg_replace( '/\.md$/', '', $absolute_path ); + + // Ensure the path starts with a forward slash and includes the page slug + return '/' . $page_slug . '/' . $absolute_path . $fragment; } ```` @@ -1195,69 +1896,6 @@ function register_weather_remote_data_block(): void { add_action( 'init', __NAMESPACE__ . '\\register_weather_remote_data_block' ); ```` -## File: example/blocks/zip-code-block/zip-code-block.php -````php - 'Zip Code', - 'endpoint' => 'https://api.zippopotam.us/us/', - ]; - - $zip_code_query = [ - 'data_source' => $zip_code_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { - return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; - }, - 'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', - ], - ], - 'output_schema' => [ - 'is_collection' => false, // This query returns a single record. - 'type' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. - 'type' => 'string', - ], - 'city' => [ - 'name' => 'City', - 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. - 'type' => 'string', - ], - 'state' => [ - 'name' => 'State', - 'path' => '$.places[0].state', - 'type' => 'string', - ], - ], - ], - ]; - - register_remote_data_block( [ - 'title' => 'Zip Code', - 'render_query' => [ - 'query' => $zip_code_query, - ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); -```` - ## File: example/templates/airtable-block/airtable-block.php ````php { - const mapElement = parentDocument.querySelector( - '.wp-block-example-leaflet-map[data-map-coordinates]' - ); - - if ( mapElement ) { - initMaps( [ mapElement ] ); - clearInterval( timer ); - } - }, 100 ); - - return () => clearInterval( timer ); - }, [] ); -} - -export function Edit() { - useMapInit(); - - // ServerSideRender allows us to reuse the markup generated by `render.php` - // instead of duplicating the rendering logic in JavaScript. - return ; -} -```` - -## File: example/templates/airtable-map-block/src/leaflet-map/index.js -````javascript -/** - * Registers a new block provided a unique name and an object defining its behavior. - * - * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ - */ -import { registerBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies - */ -import metadata from './block.json'; -import { Edit } from './edit'; - -registerBlockType( metadata.name, { - ...metadata, - edit: Edit, - save: () => null, // A pure dynamic block only serializes its attributes. -} ); -```` - -## File: example/templates/airtable-map-block/src/leaflet-map/render.php -````php - $table_id, - 'name' => 'Map locations', - 'output_query_mappings' => [ - [ - 'key' => 'id', - 'name' => 'ID', - 'path' => '$.id', - 'type' => 'id', - ], - [ - 'key' => 'name', - 'name' => 'Location name', - 'path' => '$.fields.Name', - 'type' => 'string', - ], - [ - 'key' => 'x', - 'name' => 'Latitude', - 'path' => '$.fields.x', - 'type' => 'number', - ], - [ - 'key' => 'y', - 'name' => 'Longitude', - 'path' => '$.fields.y', - 'type' => 'number', - ], - ], -]; + // `render.php`, which is good. But we don't have a way to know when the + // render is finished, so we need to poll. + const timer = setInterval( () => { + const mapElement = parentDocument.querySelector( + '.wp-block-example-leaflet-map[data-map-coordinates]' + ); -$map_data_source = AirtableDataSource::from_array( [ - 'service_config' => [ - '__version' => 1, - 'access_token' => $access_token, - 'base' => [ - 'id' => $base_id, - 'name' => 'Map locations', - ], - 'display_name' => 'Map locations', - 'tables' => [ $table ], - ], -] ); + if ( mapElement ) { + initMaps( [ mapElement ] ); + clearInterval( timer ); + } + }, 100 ); -$get_locations_query = AirtableIntegration::get_list_query( $map_data_source, $table ); -$response = $get_locations_query->execute( [] ); -$coordinates = []; + return () => clearInterval( timer ); + }, [] ); +} -if ( ! is_wp_error( $response ) ) { - $coordinates = array_map( function ( $value ) { - $result = $value['result']; - return [ - 'name' => $result['name']['value'], - 'x' => $result['x']['value'], - 'y' => $result['y']['value'], - ]; - }, $response['results'] ); +export function Edit() { + useMapInit(); + + // ServerSideRender allows us to reuse the markup generated by `render.php` + // instead of duplicating the rendering logic in JavaScript. + return ; } +```` -?> -
- data-map-coordinates="" - style="height: 400px;" -> -
+## File: example/templates/airtable-map-block/src/leaflet-map/index.js +````javascript +/** + * Registers a new block provided a unique name and an object defining its behavior. + * + * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ + */ +import { registerBlockType } from '@wordpress/blocks'; + +/** + * Internal dependencies + */ +import metadata from './block.json'; +import { Edit } from './edit'; + +registerBlockType( metadata.name, { + ...metadata, + edit: Edit, + save: () => null, // A pure dynamic block only serializes its attributes. +} ); ```` ## File: example/templates/airtable-map-block/src/leaflet-map/view.js @@ -1659,206 +2218,52 @@ function register_google_sheets_remote_data_block(): void { 'id' => $sheet_id, 'name' => $sheet_name, // These mappings correspond to the columns of the table. - 'output_query_mappings' => [ - [ - 'key' => 'row_id', - 'name' => 'Row ID', - 'path' => '$.RowId', - 'type' => 'id', - ], - [ - 'key' => 'house', - 'name' => 'House', - 'path' => '$.House', - 'type' => 'string', - ], - [ - 'key' => 'seat', - 'name' => 'Seat', - 'path' => '$.Seat', - 'type' => 'string', - ], - [ - 'key' => 'region', - 'name' => 'Region', - 'path' => '$.Region', - 'type' => 'string', - ], - [ - 'key' => 'words', - 'name' => 'Words', - 'path' => '$.Words', - 'type' => 'string', - ], - [ - 'key' => 'image_url', - 'name' => 'Sigil', - 'path' => '$.Sigil', - 'type' => 'image_url', - ], - ], - ], - ], - ], - ] ); - - GoogleSheetsIntegration::register_blocks_for_google_sheets_data_source( $westeros_houses_data_source ); -} -add_action( 'init', 'register_google_sheets_remote_data_block' ); -```` - -## File: example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php -````php - $api_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { - $endpoint = $api_data_source['endpoint']; - $item_id = $input_variables['id'] ?? ''; - - return $endpoint . '/items/' . $item_id; - }, - 'input_schema' => [ - 'id' => [ - 'name' => 'Item ID', - 'type' => 'id', - ], - ], - 'output_schema' => [ - // TODO: Adjust the field names, types, and paths based on your API - // response structure. - 'is_collection' => false, // This query returns a single record. - 'path' => '$.data', - 'type' => [ - 'id' => [ - 'name' => 'ID', - 'type' => 'id', - 'path' => '$.id', - ], - 'title' => [ - 'name' => 'Title', - 'type' => 'title', - 'path' => '$.title', - ], - 'description' => [ - 'name' => 'Description', - 'type' => 'string', - 'path' => '$.description', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'type' => 'image_url', - 'path' => '$.image_url', - ], - // TODO: Add more fields as needed. - ], - ], - ]; - - // List items query: Fetch multiple records with pagination and search. - $list_items_query = [ - 'data_source' => $api_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { - $endpoint = $api_data_source['endpoint'] . '/items'; - - $query_params = []; - - // TODO: Apply pagination input variables according to your API or remove - // if your API does not support pagination. - if ( ! empty( $input_variables['limit'] ) ) { - $query_params['limit'] = $input_variables['limit']; - } - - if ( ! empty( $input_variables['page'] ) ) { - $query_params['page'] = $input_variables['page']; - } - - // TODO: Apply search input variable according to your API or remove if - // your API does not support search. - if ( ! empty( $input_variables['search'] ) ) { - $query_params['q'] = $input_variables['search']; - } - - return add_query_arg( $query_params, $endpoint ); - }, - 'input_schema' => [ - 'search' => [ - 'name' => 'Search Terms', - 'type' => 'ui:search_input', - ], - 'limit' => [ - 'default_value' => 10, - 'name' => 'Items per page', - 'type' => 'ui:pagination_per_page', - ], - 'page' => [ - 'default_value' => 1, - 'name' => 'Page', - 'type' => 'ui:pagination_page', - ], - ], - // Reuse the output schema from the single item query. - 'output_schema' => array_merge( - $get_item_query['output_schema'], - [ 'is_collection' => true ] - ), - 'pagination_schema' => [ - // TODO: Adjust the field names, types, and paths based on your API - // response structure, or set `pagination_schema` to `null` if your API - // does not support pagination. - 'total_items' => [ - 'name' => 'Total Items', - 'path' => '$.meta.total', - ], - 'total_pages' => [ - 'name' => 'Total Pages', - 'path' => '$.meta.total_pages', - ], - 'current_page' => [ - 'name' => 'Current Page', - 'path' => '$.meta.current_page', - ], - ], - ]; - - // Register the remote data block. - register_remote_data_block( [ - 'title' => '{{ Block name }}', - 'render_query' => [ - 'query' => $get_item_query, - ], - 'selection_queries' => [ - [ - 'query' => $list_items_query, - 'type' => 'search', + 'output_query_mappings' => [ + [ + 'key' => 'row_id', + 'name' => 'Row ID', + 'path' => '$.RowId', + 'type' => 'id', + ], + [ + 'key' => 'house', + 'name' => 'House', + 'path' => '$.House', + 'type' => 'string', + ], + [ + 'key' => 'seat', + 'name' => 'Seat', + 'path' => '$.Seat', + 'type' => 'string', + ], + [ + 'key' => 'region', + 'name' => 'Region', + 'path' => '$.Region', + 'type' => 'string', + ], + [ + 'key' => 'words', + 'name' => 'Words', + 'path' => '$.Words', + 'type' => 'string', + ], + [ + 'key' => 'image_url', + 'name' => 'Sigil', + 'path' => '$.Sigil', + 'type' => 'image_url', + ], + ], + ], ], ], - // TODO: Uncomment and implement if you want to use a custom block pattern. - // 'pattern' => file_get_contents( __DIR__ . '/patterns/default-pattern.html' ), ] ); + + GoogleSheetsIntegration::register_blocks_for_google_sheets_data_source( $westeros_houses_data_source ); } -add_action( 'init', 'register_basic_rest_api_remote_data_block_from_uuid' ); +add_action( 'init', 'register_google_sheets_remote_data_block' ); ```` ## File: example/templates/shopify-product-block/shopify-product-block.php @@ -2075,447 +2480,336 @@ This folder contains a simple example theme that provides custom styling of Remo } ```` -## File: docs/concepts/inline-bindings.md -````markdown -# Inline bindings - -One of the current limitations of the [block bindings API](block-bindings.md) is that it is restricted to a small number of core blocks and attributes. For example, currently, you cannot bind to the content of a table block or a custom block. You also cannot bind to a _subset_ of a block's content. - -As a partial workaround, this plugin provides a way to use remote data in some places where block bindings are not supported. This feature is named "inline bindings" and it is available in any block that uses [rich text](https://developer.wordpress.org/block-editor/reference-guides/richtext/), such as tables, lists, and some custom blocks. Look for the inline binding button in the rich text formatting toolbar: - -Inline binding button - -Clicking this button will open a modal that allows you to select a field from a remote data source, resulting in an inline remote data binding. Just like remote data blocks, this binding will resolve from the remote source when the content is rendered. - -A bulleted list using several inline bindings to describe three conference events - -Inline bindings compile to HTML, so they are portable, safe, and have a built-in fallback. -```` - -## File: docs/extending/hooks.md -````markdown -# Hooks - -Hooks are a way for one piece of code to interact/modify another piece of code at specific, pre-defined spots. - -There are two types of hooks: Actions and Filters. To use either, you need to write a custom function known as a Callback, and then register it with a WordPress hook for a specific action or filter. - -[Read more about Hooks](https://developer.wordpress.org/plugins/hooks/) - -## Actions - -Actions allow you to add data or change how WordPress operates. Actions will run at a specific point in the execution of plugin. Callback functions for an Action do not return anything back to the calling Action hook. - -### remote_data_blocks_loaded - -This action fires when Remote Data Blocks is fully loaded and ready for use. Plugins that depend on Remote Data Blocks should use this hook to defer their initialization until Remote Data Blocks is fully loaded. - -```php -function my_plugin_init() { - // Initialize your plugin that depends on Remote Data Blocks here - // All Remote Data Blocks classes and functionality are now available -} - -if ( defined( 'REMOTE_DATA_BLOCKS__LOADED' ) ) { - // Immediately init the plugin since remote data blocks is already loaded - my_plugin_init() -} else { - // Defer the init until the remote data block is loaded - add_action( 'remote_data_blocks_loaded', 'my_plugin_init' ); -} -``` - -### remote_data_blocks_log - -If you want to send debugging information to another source besides [Query Monitor](../troubleshooting.md#query-monitor), use the `remote_data_blocks_log` action. - -```php -function custom_log( string $namespace, string $level, string $message, array $context ): void { - // Send the log to a custom destination. -} -add_action( 'remote_data_blocks_log', 'custom_log', 10, 4 ); -``` - -## Filters - -Filters give you the ability to change data during the execution of the plugin. Callback functions for Filters will accept a variable, modify it, and return it. They are meant to work in an isolated manner, and should never have side effects such as affecting global variables and output. - -### remote_data_blocks_register_example_block - -Filter whether to register the included example API block ("Conference Event") (default: `true`). - -```php -add_filter( 'remote_data_blocks_register_example_block', '__return_false' ); -``` - -### remote_data_blocks_allowed_url_schemes - -Filter the allowed URL schemes for this request. Only HTTPS is allowed by default, but it might be useful to relax this restriction in local environments. - -```php -function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterface $query ): array { - // Modify the allowed URL schemes. - return $allowed_url_schemes; -} -add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_schemes', 10, 2 ); -``` - -### remote_data_blocks_pagination_query_var_name - -Filter the query variable name used for pagination (default: `rdb-pagination`). - -```php -function custom_pagination_query_var_name(): string { - return 'paginate'; -} -add_filter( 'remote_data_blocks_pagination_query_var_name', 'custom_pagination_query_var_name', 10, 0 ); -``` - -### remote_data_blocks_request_details - -Filter the request details (method, options, url) before the HTTP request is dispatched. - -```php -function custom_request_details( array $request_details, HttpQueryInterface $query, array $input_variables ): array { - // Modify the request details. - return $request_details; -} -add_filter( 'remote_data_blocks_request_details', 'custom_request_details', 10, 3 ); -``` - -### remote_data_blocks_query_input_variables - -Filter the query input variables prior to query execution. This filter is useful for modifying the input variables for the current page-load, e.g., by pulling in data from query variables or other context. See [Overrides](overrides.md) for more information. - -```php -add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides, string $block_name, array $block_context ): array { - if ( true === in_array( 'my_override', $enabled_overrides, true ) ) { - $override_value = get_query_var( 'override_id' ); - - if ( ! empty( $override_value ) ) { - $input_variables['id'] = $override_value; - } - } - - return $input_variables; -}, 10, 4 ); -``` - -Keep in mind that modifying query input variables will affect the object cache key used for query execution. This could result in a cache miss. - -### remote_data_blocks_query_response - -Filter the query response just after query execution. This filter is useful for modifying the query response for the current page-load, e.g., by pulling in data from query variables or other context. See [Overrides](overrides.md) for more information. - -```php -add_filter( 'remote_data_blocks_query_response', function ( array $query_response, array $enabled_overrides, string $block_name, array $block_context ): array { - if ( true === in_array( 'alternate_date_format', $enabled_overrides, true ) ) { - $query_response['results'] = array_map( function ( array $result ) { - $date = new DateTime( $result['date'] ); - $result['date'] = $date->format( 'Y F d' ); - return $result; - }, $query_response['results'] ); - } - - return $input_variables; -}, 10, 4 ); -``` - -The result of this filter is not cached, and will run for every block binding. - -### remote_data_blocks_query_response_metadata - -Filter the query response metadata, which are available as targets for inline bindings. In most cases, it is better to provide a custom query class and override the `get_response_metadata` method, but this filter is available in case that is not possible. - -```php -function custom_query_response_metadata( array $metadata, HttpQueryInterface $query, array $input_variables ): array { - // Modify the response metadata. - return $metadata; -} -add_filter( 'remote_data_blocks_query_response_metadata', 'custom_query_response_metadata', 10, 3 ); -``` -```` - -## File: docs/extending/index.md +## File: example/README.md ````markdown -# Extending - -> [!TIP] -> Make sure you've read the [core concepts](../concepts/index.md) behind Remote Data Blocks before extending the plugin. - -Data sources and queries can be configured in the plugin UI but, sometimes, you need to write code to implement custom functionality or connect with data sources that aren't fully supported. Remote Data Blocks provides flexible configuration, extendable classes, hooks, and filters to help you connect to any remote data source and customize the output. - -## Customization - -Defining a data source or query in code gives you complete control over how data is fetched, processed, and rendered. In the case of unsupported APIs, it's a necessary step to define the schema and logic for fetching data. - -- [Data source](data-source.md) -- [Query](query.md) -- [Block registration](block-registration.md) - -## Advanced customization - -- [Block patterns](block-patterns.md) -- [Hooks (actions and filters)](hooks.md) -- [Overrides](overrides.md) - -## Examples and AI prompts +# Example code and templates -The included [examples](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/README.md) provide detailed code samples and templates. +The example code and templates in this directory can help you get started with the Remote Data Blocks plugin. Note that many tasks can be performed in the UI without writing any code. However, other tasks require custom code, especially when you want to work with generic REST APIs or customize the block output or behavior. -For quick development, we highly recommend [leveraging AI](ai-prompts.md) to scaffold and iterate on new integrations. +## Block examples -## Local development environment +These blocks communicate with APIs that do not require authentication. Uncomment lines at the end of `remote-data-blocks.php` to enable them. They are roughly in order of complexity, starting with the simplest. -This repository includes tools for quickly starting a [local development environment](../local-development.md). +- [Zip Code block](./blocks/zip-code-block/zip-code-block.php) +- [Art block](./blocks/art-block/art-block.php) +- [Shopify Mock Store block](./blocks/shopify-mock-store-block/shopify-mock-store-block.php) +- [Book block](./blocks/book-block/book-block.php) +- [Weather block](./blocks/weather-block/weather-block.php) +- [GitHub Markdown File block](./blocks/github-markdown-block/github-markdown-block.php) -## Data Flow +## Templates -Here's a short overview of how data flows through the plugin when a post with a remote data block is rendered: +These code templates require credentials and other customization to work. They are a useful starting point for exploration and are especially useful as context for AI agents. -1. WordPress core loads the post content, parses the blocks, and recognizes that a paragraph block has a [block binding](../concepts/block-bindings.md). -2. WordPress core calls the block binding callback function: `BlockBindings::get_value()`. -3. The callback function inspects the paragraph block. Using the block context supplied by the parent remote data block, it determines which [query](query.md) to execute. -4. The query is executed: `$query->execute()`. -5. Various properties of the query are requested by the query runner, including the endpoint, request headers, request method, and request body. Some of these properties are delegated to the data source (`$query->get_data_source()`). -6. The query is dispatched, and the response data is inspected, formatted into a consistent shape, and returned to the block binding callback function. -7. The callback function extracts the requested field from the response data and returns it to WordPress core for rendering. +- [REST API block](templates/rest-api-block) +- [REST API block from UI-created data source](templates/rest-api-block-from-ui-data-source) +- [Airtable block](templates/airtable-block) +- [Airtable map block](templates/airtable-map-block) +- [Google Sheets block](templates/google-sheets-block) +- [Shopify Product block](templates/shopify-product-block) +- [Example child theme](templates/theme) ```` -## File: docs/extending/query-input-schema.md +## File: docs/tutorials/http.md ````markdown -# HttpQuery `input_schema` property +# Create a remote data block using an HTTP data source -The `input_schema` property defines the input variables expected by the query. The property should be an associative array of input variable definitions. The keys of the array are machine-friendly input variable names, and the values are associative arrays with the following structure: +This page will walk you through registering a remote data block that loads data from a Zip code REST API. It will require you to commit code to a WordPress theme or plugin. -- `name` (optional): The human-friendly display name of the input variable -- `default_value` (optional): The default value for the input variable. -- `type` (required): The primitive type of the input variable. Supported types are: - - `boolean` - - `id` - - `integer` - - `null` - - `number` - - `string` +## Create the data source -#### Example +1. Go to Settings > Remote Data Blocks in your WordPress admin. +2. Click on the "Connect new" button. +3. Choose "HTTP" from the dropdown menu as the data source type. +4. Fill in the following details: + - Data Source Name: Zip Code API + - URL: https://api.zippopotam.us/us/ +5. If your API requires authentication, enter those details. This API does not. +6. Save the data source and return the data source list. +7. In the Actions column, click the three-dot menu, then "Copy UUID" to copy the data source's UUID to your clipboard. + +## Register the block + +Next, define a query and register a block using the data source you just created. Add this code to your theme's `functions.php` file or a custom plugin, replacing `{{ Data source UUID }}` with the UUID you copied from the data source list. ```php -'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', - ], -], -``` + [ - 'search' => [ - 'name' => 'Search terms', - 'type' => 'ui:search_input', - ], - 'limit' => [ - 'default_value' => 10, - 'name' => 'Pagination limit', - 'type' => 'ui:pagination_per_page', - ], - 'page' => [ - 'default_value' => 1, - 'name' => 'Pagination page', - 'type' => 'ui:pagination_page', - ], -], + $zip_code_query = [ + 'data_source' => $zip_code_data_source, + 'display_name' => 'Get location by Zip code', + 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { + return $zip_code_data_source->get_endpoint() . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ]; + + register_remote_data_block( [ + 'title' => 'Zip Code', + 'render_query' => [ + 'query' => $zip_code_query, + ], + ] ); +} +add_action( 'init', 'register_zip_code_remote_data_block' ); ``` -If omitted, `input_schema` defaults to an empty array. -```` +This code: -## File: docs/troubleshooting.md -````markdown -# Troubleshooting and debugging +1. Loads the data source by UUID using `HttpDataSource::from_uuid()`. +2. Defines a render query that accepts a zip code, appends it to the data source endpoint, and maps the response fields to block outputs. +3. Registers a "Zip Code" block that uses the render query. -This plugin provides a [local development environment](local-development.md) with built-in debugging tools. +For example, if the editor provides `90210`, the query requests `https://api.zippopotam.us/us/90210` and maps the `post code`, `place name`, and `state` fields from the API response. -## Query monitor +## Insert the block -When the [Query Monitor plugin](https://wordpress.org/plugins/query-monitor/) is installed and activated, Remote Data Blocks will output debugging information to a dedicated "Remote Data Blocks" panel, including error details, stack traces, query execution details, and cache hit/miss status. +Create or edit a page or post, then search for "Zip Code" in the block inserter. After inserting the block, enter a valid US zip code, such as `90210`, to fetch and display location data. -> [!TIP] -> By default, the block editor is rendered in "Fullscreen mode" which hides the Admin Bar and Query Monitor. Open the three-dot menu in the top-right corner and toggle off "Fullscreen mode", or press `⇧⌥⌘F`. +## Patterns and styling -The provided local development environment includes Query Monitor by default. You can also install it in non-local environments, but be aware that it may expose sensitive information in production environments. Query Monitor is currently not compatible with WordPress Playground and cannot be installed there. +The plugin registers an unstyled block pattern for each remote data block. You can duplicate that default pattern in the Site Editor and associate a custom pattern with your block later. Read more in [Block patterns](../extending/block-patterns.md). -## Debugging +Remote data blocks can also be styled with the block editor's style settings, `theme.json`, or custom stylesheets. -The [local development environment](local-development.md) includes Xdebug for debugging PHP code and a Node.js debugging port for debugging block editor scripts. +## Code reference -## Support +The [Zip Code block example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/blocks/zip-code-block/zip-code-block.php) shows a similar block with the data source defined entirely in code. -Our goal is to ensure that Remote Data Blocks works with as many APIs as possible. While we cannot guarantee that we can support every API, we are happy to receive detailed reports of any issues you encounter. Please [create a GitHub issue using the "API integration issue" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=api_integration_issue.md) and we will do our best to assist you. +The [REST API block from UI-created data source template](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/rest-api-block-from-ui-data-source) shows a larger template for APIs that need both render and selection queries. +```` -For general bugs, please [use the "General bug report" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=bug_report.md). If you have feedback or suggestions for improvement, please [use the "Feedback" template](https://github.com/Automattic/remote-data-blocks/issues/new?template=general_feedback.md). +## File: docs/tutorials/shopify.md +````markdown +# Create a Shopify remote data block -## Resetting config +This tutorial will walk you through connecting a [Shopify](https://www.shopify.com/) data source and how to use the automatically created block in the WordPress editor. -If you need to reset the Remote Data Blocks configuration in your local development environment, you can use WP-CLI to delete the configuration option. This will permanently delete all configuration values, including access tokens and API keys. +## Shopify API Access -```sh -npm run wp-cli option delete remote_data_blocks_config -``` -```` +To use the Shopify data source, you need a Storefront API access token for the store you want to connect. Remote Data Blocks queries Shopify's Storefront API, so the token must include the `unauthenticated_read_product_listings` scope. This allows Remote Data Blocks to read products and collections without requesting broader Admin API permissions. -## File: example/.cursor/rules/project-scope.mdc -```` ---- -description: Project scope -globs: -alwaysApply: true ---- +For new stores, follow Shopify's current [Storefront API getting started guide](https://shopify.dev/docs/storefronts/headless/building-with-the-storefront-api/getting-started): -- You are writing code that integrates with the Remote Data Blocks WordPress plugin. This plugin allows you to create Gutenberg blocks that display data from remote data sources, such as Airtable, Google Sheets, Shopify, or your own API. -- You are not contributing to the plugin directly. You are writing code that will be used in a separate plugin or theme. -- You do not need to develop custom Gutenberg blocks. Instead, you will write simple PHP code to describe how your API should be queried, then call registration functions provided by the Remote Data Blocks plugin. -- Your goal is to configure and register a remote data block that displays remote data in an organized, visually appealing way. -- The Remote Data Blocks plugin provides a default block pattern for displaying data, but it is very basic. You may need to create a custom block pattern to achieve your goal, but please ask before doing so. -```` +1. Log in to your Shopify admin account. +2. Install the Headless sales channel. +3. Create a storefront to generate Storefront API access tokens. +4. Edit the storefront's Storefront API permissions and enable product listing access. In Shopify API scope terms, this is `unauthenticated_read_product_listings`. +5. Copy the private Storefront API access token. -## File: example/blocks/github-markdown-block/inc/markdown-links.php -````php - Remote Data Blocks in your WordPress admin. +2. Click on the "Connect new" button. +3. Choose "Shopify" from the dropdown menu as the data source type. +4. Name the data source. This name is only used for display purposes. +5. Enter the subdomain of your Shopify store. To find this, log into Shopify, the subdomain of your store is the portion of the URL before `myshopify.com`. +6. Enter your access token. -/** - * Updates the relative/absolute markdown links in href attributes. - * This adjusts the links so they work correctly when the file structure changes. - * - All relative paths go one level up. - * - All absolute paths are converted to relative paths one level up. - * - Handles URLs with fragment identifiers (e.g., '#section'). - * - Removes the '.md' extension from the paths. - * - * @param string $html The HTML response data. - * @param string $current_file_path The current file's path. - * @return string The updated HTML response data. - */ -function update_markdown_links( string $html, string $current_file_path = '' ): string { - // Load the HTML into a DOMDocument - $dom = new DOMDocument(); +If the credentials are correct, you can save the data source. If you receive an error, check the token and try again. - // Convert HTML to UTF-8 using htmlspecialchars instead of mb_convert_encoding - $html = '' . $html; +## Insert the block - // Suppress errors due to malformed HTML - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); +Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. - // Create an XPath to query href attributes - $xpath = new DOMXPath( $dom ); +![How inserting a Shopify block looks in the WordPress Editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/insert-shopify-block.gif) - // Query all elements with href attributes - $nodes = $xpath->query( '//*[@href]' ); - foreach ( $nodes as $node ) { - if ( ! $node instanceof DOMElement ) { - continue; - } - $href = $node->getAttribute( 'href' ); +## Patterns and styling - // Check if the href is non-empty, points to a markdown file, and is a local path - if ( $href && - preg_match( '/\.md($|#)/', $href ) && - ! preg_match( '/^(https?:)?\/\//', $href ) - ) { - // Adjust the path - $new_href = adjust_markdown_file_path( $href, $current_file_path ); +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). - // Set the new href - $node->setAttribute( 'href', $new_href ); - } - } +Remote data blocks can be styled using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. - // Remove the data attributes that GitHub uses for click-to-copy functionality. - // The DOM parser is unable to keep them encoded correctly. - $click_to_copy_attribute = 'data-snippet-clipboard-copy-content'; - $nodes = $xpath->query( sprintf( '//*[@%s]', $click_to_copy_attribute ) ); - foreach ( $nodes as $node ) { - if ( ! $node instanceof DOMElement ) { - continue; - } - $node->removeAttribute( $click_to_copy_attribute ); - } +## Code reference - // Save and return the updated HTML without the XML declaration. - return preg_replace( '/^<\?xml[^>]+\?>/', '', $dom->saveHTML() ); -} +You can also configure Shopify integrations with code. These integrations appear in the WordPress admin but can not be modified. You may wish to do this to have more control over the data source or because you have more advanced data processing needs. + +This [working example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/shopify-product-block) will replicate what we've done in this tutorial. +```` + +## File: example/blocks/art-block/art-block.php +````php +post_name; +function register_art_remote_data_block(): void { + $aic_data_source = [ + 'display_name' => 'Art Institute of Chicago', + 'endpoint' => 'https://api.artic.edu/api/v1/artworks', + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], + ]; - // Parse the URL to separate the path and fragment - $parts = wp_parse_url( $path ); + $get_art_query = [ + 'display_name' => 'Get artwork by ID', + 'data_source' => $aic_data_source, + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $endpoint = add_query_arg( [ + 'fields' => 'id,title,image_id,artist_title', + ], $aic_data_source['endpoint'] ); - // Extract the path and fragment - $original_path = isset( $parts['path'] ) ? $parts['path'] : ''; - $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; + if ( is_array( $input_variables['id'] ) ) { + $ids = implode( ',', $input_variables['id'] ); + } else { + $ids = $input_variables['id']; + } - // Get the directory of the current file - $current_dir = dirname( $current_file_path ); + if ( ! empty( $ids ) ) { + return add_query_arg( [ 'ids' => $ids ], $endpoint ); + } - // Resolve the absolute path based on the current directory - if ( str_starts_with( $original_path, '/' ) ) { - // Already an absolute path from root, just remove leading slash - $absolute_path = ltrim( $original_path, '/' ); - } else { - // Use realpath to resolve relative paths - $temp_path = $current_dir . '/' . $original_path; - $parts = explode( '/', $temp_path ); - $absolute_parts = []; + return $endpoint; + }, + 'input_schema' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id:list', // This type indicates that the input can be a single ID or a list of IDs. + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + 'path' => '$.id', + ], + 'artist_title' => [ + 'name' => 'Artist Title', + 'type' => 'string', + 'path' => '$.artist_title', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'title', + 'path' => '$.title', + ], + 'image_url' => [ + 'name' => 'Image URL', + // Instead of a `path`, we provide a `generate` function to create the + // image URL. The `$data` parameter contains the data returned from the + // API at this "level" (e.g., after the root `path` has been applied). + 'generate' => static function ( $data ): string { + return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; + }, + 'type' => 'image_url', + ], + ], + ], + ]; - foreach ( $parts as $part ) { - if ( '.' === $part || '' === $part ) { - continue; - } - if ( '..' === $part ) { - array_pop( $absolute_parts ); - } else { - $absolute_parts[] = $part; - } - } + $search_art_query = [ + 'display_name' => 'Search artworks', + 'data_source' => $aic_data_source, + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $endpoint = $aic_data_source['endpoint'] . '/search'; + $search_terms = $input_variables['search'] ?? ''; - $absolute_path = implode( '/', $absolute_parts ); - } + // Do not include the `q` parameter if the search terms are empty. + // Otherwise, this will result in an error from the API. + if ( ! empty( $search_terms ) ) { + $endpoint = add_query_arg( [ 'q' => $search_terms ], $endpoint ); + } - // Remove the .md extension - $absolute_path = preg_replace( '/\.md$/', '', $absolute_path ); + return add_query_arg( [ + 'limit' => $input_variables['limit'], + 'fields' => 'id,title,image_id,artist_title', + 'page' => $input_variables['page'], + ], $endpoint ); + }, + 'input_schema' => [ + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Items per page', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Starting page', + 'type' => 'ui:pagination_page', + ], + ], + // Reuse the output schema from `$get_art_query`. + 'output_schema' => $get_art_query['output_schema'], + 'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.pagination.total', + 'type' => 'integer', + ], + ], + ]; - // Ensure the path starts with a forward slash and includes the page slug - return '/' . $page_slug . '/' . $absolute_path . $fragment; + register_remote_data_block( [ + 'title' => 'Art Institute of Chicago', + 'icon' => 'art', + 'render_query' => [ + 'query' => $get_art_query, + ], + 'selection_queries' => [ + [ + 'query' => $search_art_query, + 'type' => 'search', + ], + ], + ] ); } +add_action( 'init', __NAMESPACE__ . '\\register_art_remote_data_block' ); ```` ## File: example/blocks/github-markdown-block/github-markdown-block.php @@ -2535,7 +2829,7 @@ require_once __DIR__ . '/inc/markdown-links.php'; * * HttpQuery expects APIs to return JSON, but we instruct GitHub's API to return * HTML (converted from Markdown). To handle this, we provide a custom query - * runner to hangle the HTML response and update Markdown links. + * runner to handle the HTML response and update Markdown links. * * @docs /docs/extending/query.md */ @@ -2561,7 +2855,9 @@ function register_github_markdown_remote_data_block(): void { $file_extension = '.md'; $get_file_as_html_query = [ + 'display_name' => 'Get GitHub Markdown file as HTML', 'data_source' => $github_data_source, + 'cache_key_request_headers' => [ 'Accept' ], // Provide a callable (closure) to dynamically generate the endpoint using // variables in the outer scope and the input variables. 'endpoint' => function ( array $input_variables ) use ( $repo_owner, $repo_name, $repo_ref ): string { @@ -2608,6 +2904,7 @@ function register_github_markdown_remote_data_block(): void { ]; $get_list_files_query = [ + 'display_name' => 'List GitHub Markdown files', 'data_source' => $github_data_source, 'input_schema' => [ 'file_extension' => [ @@ -2686,22 +2983,169 @@ function handle_github_file_path_override(): void { return $query_vars; }, 10, 1 ); - // Filter the query input variables to inject the "file_path" value from the - // URL. Note that the override must match the override name defined in the - // block registration above. - add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides ): array { - if ( true === in_array( 'github_file_path', $enabled_overrides, true ) ) { - $file_path = get_query_var( 'file_path' ); + // Filter the query input variables to inject the "file_path" value from the + // URL. Note that the override must match the override name defined in the + // block registration above. + add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides ): array { + if ( true === in_array( 'github_file_path', $enabled_overrides, true ) ) { + $file_path = get_query_var( 'file_path' ); + + if ( ! empty( $file_path ) ) { + $input_variables['file_path'] = $file_path; + } + } + + return $input_variables; + }, 10, 2 ); +} +add_action( 'init', __NAMESPACE__ . '\\handle_github_file_path_override' ); +```` + +## File: example/blocks/zip-code-block/zip-code-block.php +````php + 'Zip Code', + 'endpoint' => 'https://api.zippopotam.us/us/', + ]; + + $zip_code_query = [ + 'data_source' => $zip_code_data_source, + 'display_name' => 'Get location by Zip code', + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { + return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, // This query returns a single record. + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ]; + + register_remote_data_block( [ + 'title' => 'Zip Code', + 'render_query' => [ + 'query' => $zip_code_query, + ], + ] ); +} +add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); +```` + +## File: example/templates/airtable-map-block/src/leaflet-map/render.php +````php + $table_id, + 'name' => 'Map locations', + 'output_query_mappings' => [ + [ + 'key' => 'id', + 'name' => 'ID', + 'path' => '$.id', + 'type' => 'id', + ], + [ + 'key' => 'name', + 'name' => 'Location name', + 'path' => '$.fields.Name', + 'type' => 'string', + ], + [ + 'key' => 'x', + 'name' => 'Latitude', + 'path' => '$.fields.x', + 'type' => 'number', + ], + [ + 'key' => 'y', + 'name' => 'Longitude', + 'path' => '$.fields.y', + 'type' => 'number', + ], + ], +]; + +$map_data_source = AirtableDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => $access_token, + 'base' => [ + 'id' => $base_id, + 'name' => 'Map locations', + ], + 'display_name' => 'Map locations', + 'tables' => [ $table ], + ], +] ); - if ( ! empty( $file_path ) ) { - $input_variables['file_path'] = $file_path; - } - } +$coordinates = []; - return $input_variables; - }, 10, 2 ); +if ( ! is_wp_error( $map_data_source ) ) { + $get_locations_query = HttpQuery::from_array( AirtableIntegration::get_list_query( $map_data_source, $table ) ); + $response = is_wp_error( $get_locations_query ) ? $get_locations_query : $get_locations_query->execute( [] ); + + if ( ! is_wp_error( $response ) ) { + $coordinates = array_map( function ( $value ) { + $result = $value['result']; + return [ + 'name' => $result['name']['value'], + 'x' => $result['x']['value'], + 'y' => $result['y']['value'], + ]; + }, $response['results'] ); + } } -add_action( 'init', __NAMESPACE__ . '\\handle_github_file_path_override' ); + +?> +
+ data-map-coordinates="" + style="height: 400px;" +> +
```` ## File: example/templates/rest-api-block/rest-api-block.php @@ -2726,7 +3170,11 @@ function register_basic_rest_api_remote_data_block(): void { // Get item query: Fetch one record by ID. $get_item_query = [ + 'display_name' => 'Get item by ID', 'data_source' => $api_data_source, + // Include every custom request header above that can affect authentication, + // authorization, tenancy, or the returned data. + 'cache_key_request_headers' => [ 'X-API-Key' ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { @@ -2742,458 +3190,249 @@ function register_basic_rest_api_remote_data_block(): void { ], ], 'output_schema' => [ - // TODO: Adjust the field names, types, and paths based on your API - // response structure. - 'is_collection' => false, // This query returns a single record. - 'path' => '$.data', - 'type' => [ - 'id' => [ - 'name' => 'ID', - 'type' => 'id', - 'path' => '$.id', - ], - 'title' => [ - 'name' => 'Title', - 'type' => 'title', - 'path' => '$.title', - ], - 'description' => [ - 'name' => 'Description', - 'type' => 'string', - 'path' => '$.description', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'type' => 'image_url', - // Instead of a `path`, we provide a `generate` function to create the - // image URL. The `$data` parameter contains the data returned from the - // API at this "level" (e.g., after the root `path` has been applied). - // - // It also receives the raw response data, which can be useful if you - // need to access input variables or other data not available in the - // response. - 'generate' => static function ( array $data, array $raw_response_data ): string { - $item_id = $data['id'] ?? $raw_response_data['input_variables']['id']; - return 'https://example.com/images/items/' . $item_id . '.jpg'; - }, - ], - // TODO: Add more fields as needed. - ], - ], - ]; - - // List items query: Fetch multiple records with pagination and search. - $list_items_query = [ - 'data_source' => $api_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { - $endpoint = $api_data_source['endpoint'] . '/items'; - - $query_params = []; - - // TODO: Apply pagination input variables according to your API or remove - // if your API does not support pagination. - if ( ! empty( $input_variables['limit'] ) ) { - $query_params['limit'] = $input_variables['limit']; - } - - if ( ! empty( $input_variables['page'] ) ) { - $query_params['page'] = $input_variables['page']; - } - - // TODO: Apply search input variable according to your API or remove if - // your API does not support search. - if ( ! empty( $input_variables['search'] ) ) { - $query_params['q'] = $input_variables['search']; - } - - return add_query_arg( $query_params, $endpoint ); - }, - 'input_schema' => [ - 'search' => [ - 'name' => 'Search Terms', - 'type' => 'ui:search_input', - ], - 'limit' => [ - 'default_value' => 10, - 'name' => 'Items per page', - 'type' => 'ui:pagination_per_page', - ], - 'page' => [ - 'default_value' => 1, - 'name' => 'Page', - 'type' => 'ui:pagination_page', - ], - ], - // Reuse the output schema from the single item query. - 'output_schema' => array_merge( - $get_item_query['output_schema'], - [ 'is_collection' => true ] - ), - 'pagination_schema' => [ - // TODO: Adjust the field names, types, and paths based on your API - // response structure, or set `pagination_schema` to `null` if your API - // does not support pagination. - 'total_items' => [ - 'name' => 'Total Items', - 'path' => '$.meta.total', - ], - 'total_pages' => [ - 'name' => 'Total Pages', - 'path' => '$.meta.total_pages', - ], - 'current_page' => [ - 'name' => 'Current Page', - 'path' => '$.meta.current_page', - ], - ], - ]; - - // Register the remote data block. - register_remote_data_block( [ - 'title' => '{{ Block name }}', - 'render_query' => [ - 'query' => $get_item_query, - ], - 'selection_queries' => [ - [ - 'query' => $list_items_query, - 'type' => 'search', - ], - ], - // TODO: Uncomment and implement if you want to use a custom block pattern. - // 'pattern' => file_get_contents( __DIR__ . '/patterns/default-pattern.html' ), - ] ); -} -add_action( 'init', 'register_basic_rest_api_remote_data_block' ); -```` - -## File: docs/concepts/helper-blocks.md -````markdown -# Helper Blocks - -Remote Data Blocks adds some accessory blocks for bindings, listed below. - -## Remote HTML Block - -Use this block to bind to HTML from a remote data source. This block only works when placed inside a remote data block container and bound to a field containing HTML. - -![Screen recording showing the insertion and binding of a Remote HTML Block in the editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/block-insert-remote-html.gif) - -Fields defined by a query’s `output_schema` must have type `html` in order to be available to Remote HTML blocks: - -```php -$my_query = [ - /* ... */ - 'output_schema' => - 'is_collection' => false, - 'output_schema' => [ - 'type' => [ - 'header' => [ - 'name' => 'Header', - 'path' => '$.header', - 'type' => 'string', - ], - 'myHtmlContent' => [ - 'name' => 'My HTML Content', - 'path' => '$.myHtmlContent', - 'type' => 'html', // <-- required - ], - ], - ], -]; - -register_remote_data_block( [ - 'title' => 'My HTML API', - 'render_query' => [ - 'query' => $my_query, - ], -] ); -``` - -## No Results Block - -This block is used to display a message or content when a remote data block query returns no results. It is automatically inserted whenever you use a query that resolves to a collection, even if the collection is not currently empty. -```` - -## File: docs/extending/block-registration.md -````markdown -# Block registration - -Use the `register_remote_data_block` function to register your remote data block and associate it with your query and data source. This example: - -1. Creates a [data source](data-source.md). -2. Associates the data source with a query. -3. Defines the output schema of a query, which tells the plugin how to map the query response to blocks. -4. Registers a remote data block. - -We are assuming `https://api.example.com/` returns JSON that has a shape like: - -```json -{ - "id": 12345, - "title": "An awesome title" -} -``` - -```php -function register_your_custom_block() { - $data_source = [ - 'display_name' => 'Example API', - 'endpoint' => 'https://api.example.com/', - ]; - - $render_query = [ - 'display_name' => 'Example Query', - 'data_source' => $data_source, - 'output_schema' => [ + // TODO: Adjust the field names, types, and paths based on your API + // response structure. + 'is_collection' => false, // This query returns a single record. + 'path' => '$.data', 'type' => [ 'id' => [ 'name' => 'ID', - 'path' => '$.id', 'type' => 'id', + 'path' => '$.id', ], 'title' => [ 'name' => 'Title', + 'type' => 'title', 'path' => '$.title', + ], + 'description' => [ + 'name' => 'Description', 'type' => 'string', + 'path' => '$.description', + ], + 'image_url' => [ + 'name' => 'Image URL', + 'type' => 'image_url', + // Instead of a `path`, we provide a `generate` function to create the + // image URL. The `$data` parameter contains the data returned from the + // API at this "level" (e.g., after the root `path` has been applied). + // + // It also receives the raw response data, which can be useful if you + // need to access input variables or other data not available in the + // response. + 'generate' => static function ( array $data, array $raw_response_data ): string { + $item_id = $data['id'] ?? $raw_response_data['input_variables']['id']; + return 'https://example.com/images/items/' . $item_id . '.jpg'; + }, ], + // TODO: Add more fields as needed. ], ], ]; - register_remote_data_block( [ - 'title' => 'My Block', - 'render_query' => [ - 'query' => $render_query, - ], - ] ); -} -add_action( 'init', 'register_your_custom_block', 10, 0 ); -``` - -## Configuration options - -### `title`: string (required) - -The human-friendly name of the block. It is also used to construct the block's name; a title of "My Block" will result in a block name of `remote-data-blocks/my-block`. - -### `render_query`: array (required) - -The render query is executed when the block is rendered and fetches the data that will be provided to block bindings. It is an array with the following properties: - -- `query` (required): An instance of [`QueryInterface`](./query.md) that fetches the data. - -### `selection_queries`: array (optional) - -Selection queries are used by content creators to select or curate remote data in the block editor. For example, you may wish to provide a list of products to users and allow them to select one to include in their post, or you may want to allow a user to search for a specific item. Selection queries are an array of objects with the following properties: - -- `display_name`: A human-friendly name for the selection query. -- `query` (required): An instance of `QueryInterface` that fetches the data. -- `type`: A string that determines the type of selection query. Accepted values are currently `list` or `search`. - -Example: + // List items query: Fetch multiple records with pagination and search. + $list_items_query = [ + 'display_name' => 'List items', + 'data_source' => $api_data_source, + // Include every custom request header above that can affect authentication, + // authorization, tenancy, or the returned data. + 'cache_key_request_headers' => [ 'X-API-Key' ], + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { + $endpoint = $api_data_source['endpoint'] . '/items'; -```php -'selection_queries' => [ - [ - 'display_name' => 'Select a product', - 'query' => $list_products_query, - 'type' => 'list', - ], - [ - 'display_name' => 'Search for a product', - 'query' => $search_products_query, - 'type' => 'search', - ], -], -``` + $query_params = []; -#### Search queries + // TODO: Apply pagination input variables according to your API or remove + // if your API does not support pagination. + if ( ! empty( $input_variables['limit'] ) ) { + $query_params['limit'] = $input_variables['limit']; + } -Search queries must return a collection and must accept an input variable with the special type `ui:search_input`. The [Art block](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/art-block/art-block.php) example looks like this: + if ( ! empty( $input_variables['page'] ) ) { + $query_params['page'] = $input_variables['page']; + } -```php -$search_art_query = [ - 'data_source' => $aic_data_source, - 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $query = $input_variables['search']; - $endpoint = $aic_data_source->get_endpoint() . '/search'; + // TODO: Apply search input variable according to your API or remove if + // your API does not support search. + if ( ! empty( $input_variables['search'] ) ) { + $query_params['q'] = $input_variables['search']; + } - return add_query_arg( [ 'q' => $query ], $endpoint ); - }, - 'input_schema' => [ - 'search' => [ - 'name' => 'Search terms', - 'type' => 'ui:search_input', + return add_query_arg( $query_params, $endpoint ); + }, + 'input_schema' => [ + 'search' => [ + 'name' => 'Search Terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Items per page', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Page', + 'type' => 'ui:pagination_page', + ], ], - ], - 'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'type' => 'id', + // Reuse the output schema from the single item query. + 'output_schema' => array_merge( + $get_item_query['output_schema'], + [ 'is_collection' => true ] + ), + 'pagination_schema' => [ + // TODO: Adjust the field names, types, and paths based on your API + // response structure, or set `pagination_schema` to `null` if your API + // does not support pagination. + 'total_items' => [ + 'name' => 'Total Items', + 'path' => '$.meta.total', ], - 'title' => [ - 'name' => 'Title', - 'type' => 'string', + 'total_pages' => [ + 'name' => 'Total Pages', + 'path' => '$.meta.total_pages', + ], + 'current_page' => [ + 'name' => 'Current Page', + 'path' => '$.meta.current_page', ], ], - ], -]; -``` - -Here you can see the `search` input variable has a special type of `ui:search_input` and is used in the endpoint method to populate a query string. You can read more about [queries](./query.md) and how to construct them. End users enter the search term to find the specific item. - -![Screenshot showing the search input in the WordPress Editor](https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/docs/assets/search-input.png) - -**Note:** The same search box appears for `list` query types. For this type, the form is only filtering the results returned by the initial list query. For `search` queries, an additional query is made for every search. - -### `overrides`: array (optional) - -[Overrides](overrides.md) are used to customize the behavior of the block on a per-block basis. - -### `patterns`: array (optional) - -[Block patterns](block-patterns.md) allow you to customize the display of your remote data. -```` - -## File: docs/tutorials/http.md -````markdown -# Create a remote data block using an HTTP data source - -This page will walk you through registering a remote data block that loads data from a Zip code REST API. It will require you to commit code to a WordPress theme or plugin. - -## Create the data source - -1. Go to Settings > Remote Data Blocks in your WordPress admin. -2. Click on the "Connect new" button. -3. Choose "HTTP" from the dropdown menu as the data source type. -4. Fill in the following details: - - Data Source Name: Zip Code API - - URL: https://api.zippopotam.us/us/ -5. If your API requires authentication, enter those details. This API does not. -6. Save the data source and return the data source list. -7. In the Actions column, click the three-dot menu, then "Copy UUID" to copy the data source's UUID to your clipboard. - -## Register the block - -In code, we'll define a query using the data source we just created. Follow the [Zip code block example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/blocks/zip-code-block/zip-code-block.php), but remove the data source definition. In its place, use this code to load the data source we just created by its UUID: + ]; -```php -$data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); -``` + // Register the remote data block. + register_remote_data_block( [ + 'title' => '{{ Block name }}', + 'render_query' => [ + 'query' => $get_item_query, + ], + 'selection_queries' => [ + [ + 'query' => $list_items_query, + 'type' => 'search', + ], + ], + // TODO: Uncomment and implement if you want to use a custom block pattern. + // 'pattern' => file_get_contents( __DIR__ . '/patterns/default-pattern.html' ), + ] ); +} +add_action( 'init', 'register_basic_rest_api_remote_data_block' ); ```` -## File: example/blocks/art-block/art-block.php +## File: example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php ````php - 'Art Institute of Chicago', - 'endpoint' => 'https://api.artic.edu/api/v1/artworks', - 'request_headers' => [ - 'Content-Type' => 'application/json', - ], - ]; - - $get_art_query = [ - 'data_source' => $aic_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $endpoint = add_query_arg( [ - 'fields' => 'id,title,image_id,artist_title', - ], $aic_data_source['endpoint'] ); +function register_basic_rest_api_remote_data_block_from_uuid(): void { + $api_data_source = HttpDataSource::from_uuid( '{{ UUID of the data source }}' ); - if ( is_array( $input_variables['id'] ) ) { - $ids = implode( ',', $input_variables['id'] ); - } else { - $ids = $input_variables['id']; - } + if ( is_wp_error( $api_data_source ) ) { + return; + } - if ( ! empty( $ids ) ) { - return add_query_arg( [ 'ids' => $ids ], $endpoint ); - } + // Get item query: Fetch one record by ID. + $get_item_query = [ + 'data_source' => $api_data_source, + 'cache_key_request_headers' => [ + // TODO: Include every custom header from the UI-configured data source that + // can affect authentication, authorization, tenancy, or the returned data. + // 'X-API-Key', + ], + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { + $endpoint = $api_data_source->get_endpoint(); + $item_id = $input_variables['id'] ?? ''; - return $endpoint; + return $endpoint . '/items/' . $item_id; }, 'input_schema' => [ 'id' => [ - 'name' => 'Art ID', - 'type' => 'id:list', // This type indicates that the input can be a single ID or a list of IDs. + 'name' => 'Item ID', + 'type' => 'id', ], ], 'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', + // TODO: Adjust the field names, types, and paths based on your API + // response structure. + 'is_collection' => false, // This query returns a single record. + 'path' => '$.data', 'type' => [ 'id' => [ - 'name' => 'Art ID', + 'name' => 'ID', 'type' => 'id', 'path' => '$.id', ], - 'artist_title' => [ - 'name' => 'Artist Title', - 'type' => 'string', - 'path' => '$.artist_title', - ], 'title' => [ 'name' => 'Title', 'type' => 'title', 'path' => '$.title', ], + 'description' => [ + 'name' => 'Description', + 'type' => 'string', + 'path' => '$.description', + ], 'image_url' => [ 'name' => 'Image URL', - // Instead of a `path`, we provide a `generate` function to create the - // image URL. The `$data` parameter contains the data returned from the - // API at this "level" (e.g., after the root `path` has been applied). - 'generate' => static function ( $data ): string { - return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; - }, 'type' => 'image_url', + 'path' => '$.image_url', ], + // TODO: Add more fields as needed. ], ], ]; - $search_art_query = [ - 'data_source' => $aic_data_source, + // List items query: Fetch multiple records with pagination and search. + $list_items_query = [ + 'data_source' => $api_data_source, + 'cache_key_request_headers' => [ + // TODO: Include every custom header from the UI-configured data source that + // can affect authentication, authorization, tenancy, or the returned data. + // 'X-API-Key', + ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $endpoint = $aic_data_source['endpoint'] . '/search'; - $search_terms = $input_variables['search'] ?? ''; + 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { + $endpoint = $api_data_source->get_endpoint() . '/items'; - // Do not include the `q` parameter if the search terms are empty. - // Otherwise, this will result in an error from the API. - if ( ! empty( $search_terms ) ) { - $endpoint = add_query_arg( [ 'q' => $search_terms ], $endpoint ); + $query_params = []; + + // TODO: Apply pagination input variables according to your API or remove + // if your API does not support pagination. + if ( ! empty( $input_variables['limit'] ) ) { + $query_params['limit'] = $input_variables['limit']; } - return add_query_arg( [ - 'limit' => $input_variables['limit'], - 'fields' => 'id,title,image_id,artist_title', - 'page' => $input_variables['page'], - ], $endpoint ); + if ( ! empty( $input_variables['page'] ) ) { + $query_params['page'] = $input_variables['page']; + } + + // TODO: Apply search input variable according to your API or remove if + // your API does not support search. + if ( ! empty( $input_variables['search'] ) ) { + $query_params['q'] = $input_variables['search']; + } + + return add_query_arg( $query_params, $endpoint ); }, 'input_schema' => [ 'search' => [ - 'name' => 'Search terms', + 'name' => 'Search Terms', 'type' => 'ui:search_input', ], 'limit' => [ @@ -3203,687 +3442,638 @@ function register_art_remote_data_block(): void { ], 'page' => [ 'default_value' => 1, - 'name' => 'Starting page', + 'name' => 'Page', 'type' => 'ui:pagination_page', ], ], - // Reuse the output schema from `$get_art_query`. - 'output_schema' => $get_art_query['output_schema'], + // Reuse the output schema from the single item query. + 'output_schema' => array_merge( + $get_item_query['output_schema'], + [ 'is_collection' => true ] + ), 'pagination_schema' => [ + // TODO: Adjust the field names, types, and paths based on your API + // response structure, or set `pagination_schema` to `null` if your API + // does not support pagination. 'total_items' => [ - 'name' => 'Total items', - 'path' => '$.pagination.total', - 'type' => 'integer', + 'name' => 'Total Items', + 'path' => '$.meta.total', + ], + 'total_pages' => [ + 'name' => 'Total Pages', + 'path' => '$.meta.total_pages', + ], + 'current_page' => [ + 'name' => 'Current Page', + 'path' => '$.meta.current_page', ], ], ]; + // Register the remote data block. register_remote_data_block( [ - 'title' => 'Art Institute of Chicago', - 'icon' => 'art', + 'title' => '{{ Block name }}', 'render_query' => [ - 'query' => $get_art_query, + 'query' => $get_item_query, ], 'selection_queries' => [ [ - 'query' => $search_art_query, + 'query' => $list_items_query, 'type' => 'search', ], ], + // TODO: Uncomment and implement if you want to use a custom block pattern. + // 'pattern' => file_get_contents( __DIR__ . '/patterns/default-pattern.html' ), ] ); } -add_action( 'init', __NAMESPACE__ . '\\register_art_remote_data_block' ); -```` - -## File: example/README.md -````markdown -# Example code and templates - -The example code and templates in this directory can help you get started with the Remote Data Blocks plugin. Note that many tasks can be performed in the UI without writing any code. However, other tasks require custom code, especially when you want to work with generic REST APIs or customize the block output or behavior. - -## Block examples - -These blocks communicate with APIs that do not require authentication. Uncomment lines at the end of `remote-data-blocks.php` to enable them. They are roughly in order of complexity, starting with the simplest. - -- [Zip Code block](./blocks/zip-code-block/zip-code-block.php) -- [Art block](./blocks/art-block/art-block.php) -- [Shopify Mock Store block](./blocks/shopify-mock-store-block/shopify-mock-store-block.php) -- [Book block](./blocks/book-block/book-block.php) -- [Weather block](./blocks/weather-block/weather-block.php) -- [GitHub Markdown File block](./blocks/github-markdown-block/github-markdown-block.php) - -## Templates - -These code templates require credentials and other customization to work. They are a useful starting point for exploration and are especially useful as context for AI agents. - -- [REST API block](templates/rest-api-block) -- [REST API block from UI-created data source](templates/rest-api-block-from-ui-data-source) -- [Airtable block](templates/airtable-block) -- [Airtable map block](templates/airtable-map-block) -- [Google Sheets block](templates/google-sheets-block) -- [Shopify Product block](templates/shopify-product-block) -- [Example child theme](templates/theme) -```` - -## File: docs/concepts/index.md -````markdown -# Core concepts - -Remote Data Blocks allows you to integrate remote data into posts, pages, patterns, or anywhere else on your site where you use the block editor. This guide will help you understand the core concepts of the plugin and how they work. - -## What is a remote data block? - -A **remote data block** is a custom block that fetches, caches, and displays remote data from an external data source. For example, using this plugin, you can create a remote data block named "Shopify Product" that fetches a product from your Shopify store and displays the product's name, description, price, and image. Or, you might have a remote data block named "Conference event" that displays rows from an Airtable and displays the event's name, location, and type. - -Remote data blocks are **container blocks** that provide remote data to its inner blocks via [the block bindings API](block-bindings.md) or [inline bindings](inline-bindings.md). You retain complete control over the layout, design, and content of a remote data block and its inner blocks. You can leverage patterns to enable consistent styling and customize the block's appearance using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. - -Remote data blocks are created and registered by this plugin and don't require custom block development. In addition, [helper blocks](helper-blocks.md) are also provided to perform specific tasks. - -## Caching - -This plugin offers a caching layer for optimal performance. It will be used if your WordPress environment configures a [persistent object cache](https://developer.wordpress.org/reference/classes/wp_object_cache/#persistent-cache-plugins). Otherwise, the plugin will utilize in-memory (per-page-load) caching. Deploying to production without a persistent object cache is not recommended. - -The default TTL for all cache objects is 5 minutes, but it can be [configured per query or request](../extending/query.md#cache_ttl-intnullcallable). Error responses are cached for 30 seconds to avoid overwhelming the remote data source under error conditions. Multiple requests for the same data within a single page load will be deduplicated even if the requests are not cacheable. - -## Technical concepts - -If you want to understand the internals of Remote Data Blocks so that you can write code to extend its functionality, head over to the [extending guide](../extending/index.md). - -## Supported use cases - -Like WordPress, Remote Data Blocks is flexible. It can be used to enable advanced integrations with external data. - -Below, you'll find specific use cases where Remote Data Blocks shines. We are working to expand these use cases, but before you start, consider if Remote Data Blocks is the right tool for the job. - -### Remote Data Blocks is a good fit if: - -- Your remote data represents entities with a consistent schema. - - **Example:** Product data representing items of clothing with defined attributes like “Name,” “Price,” “Color,” “Size,” etc. -- You want humans to select specific entities for display within the block editor. - - **Example:** Select and display an item of clothing within a marketing post. -- You want to display arbitrary remote data based on a URL parameter and are willing to write a small amount of code. - - **Example:** Create a page and rewrite rule for /products/{product_id}/ and configure a Remote Data Block on that page to display the referenced product. -- Your presentation of remote data aligns with the capabilities of [block bindings](block-bindings.md). - - **Example:** Display an item of clothing using a core paragraph, heading, image, and button blocks. -- Your data is denormalized. - - **Example:** A row from a Google Sheet with no references to external entities. - -### Remote Data Blocks may not be a good fit if: - -- Your remote data is schema-less, or the schema changes over time. - - Queries for remote data must define a schema for their return data. Schema changes result in broken blocks. -- You want to display remote data outside the context of the block editor. - - Block bindings are only available in block content—posts, pages, or full-site editing. Using our plugin to define and resolve remote data may still provide some benefit (e.g., caching) but could require significant custom PHP code. -- Your data is normalized (and cannot be denormalized automatically by your API). - - Some APIs can denormalize data by automatically “inflating” referenced records for you. For example, data representing an item of clothing might reference a color by ID instead of a renderable string like “forest green.” If your API does not denormalize this relationship automatically, you will need to write custom code to perform additional queries and stitch the responses together. - - This can lead to a large number of API requests that your API may not tolerate. Airtable’s API, for example, imposes a rate limit of five requests per second, making multiple calls impractical. -- You have multiple remote data sources that require interaction with each other. Or, you want to implement a complex content architecture using Remote Data Blocks instead of leveraging WordPress custom post types and/or taxonomies. - - These two challenges are directly related to the issues with normalized data. If you have data sources that relate to one another, you must write custom code to query missing data and stitch them together. - - Judging complexity is difficult, but implementing large applications using Remote Data Blocks is not advisable. -- Your use case requires complex filtering of remote data or your API uses non-standard pagination. - - Our UI components for filtering and pagination are still under development. - -Over time, Remote Data Blocks will grow and improve and these guidelines will change. +add_action( 'init', 'register_basic_rest_api_remote_data_block_from_uuid' ); ```` -## File: docs/extending/data-source.md +## File: docs/extending/query.md ````markdown -# Data source - -A data source defines the basic reusable properties of an API and is used by a [query](query.md) to reduce duplicative code. It also helps define how your data source looks in the WordPress admin. +# Query -Simple data sources can be configured via the plugin's settings screen, while others may require custom PHP code. +A query defines a request for data from a [data source](data-source.md). It defines input and output variables so that the Remote Data Blocks plugin knows how to interact with it. [Built-in services](data-source.md#built-in-services) offer automatic query registration. -## Example +## Code example -Here's an example of a data source configuration for an HTTP API: +Here is an example of a query that fetches Zip code data: ```php -$data_source = [ - 'display_name' => 'Example API', - 'endpoint' => 'https://api.example.com/', - 'request_headers' => [ - 'Content-Type' => 'application/json', - 'X-Api-Key' => constant( 'MY_API_KEY_CONSTANT' ), +$zip_code_data_source = [ + 'display_name' => 'Zip Code API', + 'endpoint' => 'https://api.zippopotam.us/us/', +]; + +$zip_code_query = [ + 'data_source' => $zip_code_data_source, + 'display_name' => 'Get location by Zip code', + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { + return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, // This query returns a single record. + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], ], ]; ``` -And here is an example of a data source that was defined in the plugin settings screen, loaded by its UUID: +- The `endpoint` property is a callback function that constructs the query endpoint. In this case, the endpoint is constructed by appending the `zip_code` input variable to the data source endpoint. +- The `input_schema` property defines the input variables the query expects. For some queries, input variables might be used to construct a request body. In this case, the `zip_code` input variable is used to customize the query endpoint via the `endpoint` callback function. +- The `output_schema` property defines the output data that will be extracted from the API response and provided to the remote data block. The `path` property uses [JSONPath](https://jsonpath.com/) expressions to allow concise, no-code references to nested data. -```php -$data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); -``` +This example features a small subset of the customization available for a query; see the full documentation below for details. ## Configuration -### display_name: string (required) - -The display name is used in the UI to identify your data source. - -### endpoint: string (required) +### display_name: string -This is the default or base endpoint for the data source. [Queries](query.md) that use a data source can override or append paths to its endpoint. +The `display_name` property defines the query's human-friendly name. -### image_url: string +### data_source: array|HttpDataSourceInterface (required) -An optional image URL can be used in the UI to help identify your data source. +The `data_source` property provides the [data source](./data-source.md) the query uses. It can be an array containing configuration (as in the example above) or an instance of a class that implements `HttpDataSourceInterface`. -### request_headers: array +### endpoint: string|callable -An associative array of headers that will be sent with each HTTP request. Queries that use a data source can override or append headers. +The `endpoint` property defines the query endpoint. It can be a string or a callable function that constructs the endpoint. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will use the endpoint defined by the data source. -When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. -```` +#### Example -## File: docs/extending/query-output-schema.md -````markdown -# HttpQuery `output_schema` property +```php +'endpoint' => function( array $input_variables ) use ( $data_source ): string { + return $data_source-['endpoint'] . $input_variables['zip_code']; +}, +``` -A query's `output_schema` defines how an API response should be transformed and provided to a remote data block. A typical goal is to transform the API response into a flat array of fields that can be bound to blocks, while omitting values that are not needed. Output can be nested, but nested values cannot be bound to blocks. +### input_schema: array -Note that the output schema may require updates whenever the shape or schema of the API response changes. Similarly, changing the slug or `type` of a field may break existing bindings. Consider creating a new query and remote data block if you need to make breaking changes to an output schema. +The `input_schema` property defines the input variables expected by the query, which can be used to formulate the endpoint, the request headers, or the request body. Further specification and examples are provided in the [`input_schema` documentation](./query-input-schema.md). -## Properties +### output_schema: array (required) -- `format` (optional): A callable function that formats the output variable value. -- `generate` (optional): A callable function that generates or extracts the output variable value from the response, as an alternative to `path`. It receives two parameters: - - `array $data`: The data returned by the API, which is contains the data returned from the API at the current "level" (e.g., after the root `path` has been applied, if present). - - `array $raw_response_data`: The "raw" response data returned by the API, which includes the input variables (`$raw_response_data['input_variables']`), response metadata (`$raw_response_data['metadata']`), and the entire API response before any preprocessing. -- `is_collection` (optional, default `false`): A boolean indicating whether the response data is a collection. If false, only a single item will be returned. -- `name` (optional): The human-friendly display name of the output variable. -- `default_value` (optional): The default value for the output variable. -- `path` (optional): A [JSONPath](https://jsonpath.com/) expression to extract the variable value from the response. Note that path expressions are relative to the current item and its type; path expressions therefore "build" on each other when you nest types. -- `type` (required): A primitive type (e.g., `string`, `boolean`) or a nested output schema. +The `output_schema` property defines how an API response should be transformed and provided to a remote data block. Further information and examples are provided in the [`output_schema` documentation](./query-output-schema.md). -Accepted primitive types are: +### pagination_schema: array -- `boolean` -- `button_url` -- `email_address` -- `html` -- `id` -- `image_alt` -- `image_url` -- `integer` -- `markdown` -- `null` -- `number` -- `string` -- `url` -- `uuid` +If your query supports pagination, the `pagination_schema` property defines how to extract pagination-related values from the query response. If defined, the property should be an associative array with the following structure: -## Single entity example +- `total_items`: A variable definition that extracts the total number of items across every page of results. +- `has_next_page`: A variable definition that extracts a boolean indicating whether there are more pages of results. Useful for APIs that do not report the total number of items. +- `cursor_next`: If your query supports cursor pagination, a variable definition that extracts the cursor for the next page of results. This output variable will also be mapped to `ui:pagination_cursor`, if present. +- `cursor_previous`: If your query supports cursor pagination, a variable definition that extracts the cursor for the previous page of results. -Using the [Zip Code block](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/zip-code-block/zip-code-block.php), the JSON response returned by the API looks like this: +Note that one of `has_next_page` or `total_items` is required for all pagination types. -```json -{ - "post code": "17057", - "country": "United States", - "country abbreviation": "US", - "places": [ - { - "place name": "Middletown", - "longitude": "-76.7331", - "state": "Pennsylvania", - "state abbreviation": "PA", - "latitude": "40.2041" - } - ] -} -``` +A pagination block will automatically be added to remote data blocks that support pagination. -And the corresponding `output_schema` definition might look like this: +#### Example ```php -'output_schema' => [ - 'is_collection' => false, - 'type' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'path' => '$["post code"]', - 'type' => 'string', - ], - 'city_state' => [ - 'name' => 'City, State', - 'default_value' => 'Unknown', - 'generate' => function( array $data, array $raw_response_data ): string|null { - if ( empty( $data['places'] ) ) { - return null; - } - - return $data['places'][0]['place name'] . ', ' . $data['places'][0]['state abbreviation']; - }, - 'type' => 'string', - ], +'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.pagination.totalItems', + 'type' => 'integer', + ], + 'cursor_next' => [ + 'name' => 'Next page cursor', + 'path' => '$.pagination.nextCursor', + 'type' => 'string', + ], + 'cursor_previous' => [ + 'name' => 'Previous page cursor', + 'path' => '$.pagination.previousCursor', + 'type' => 'string', ], ], ``` -- The `is_collection` property indicates whether the output represents a single entity or a collection of entities. In this case, it is set to `false` because the API returns a single entity. -- The `type` property at the root level begins the type definition. The `zip_code` and `city_state` array keys are "slugs" that identify the field. The array values define types that describe how to extract a value for those fields. -- The `zip_code` field is extracted via a [JSONPath](http://jsonpath.com) expression defined in the `path` property. -- The `city_state` field provides a callable via the `generate` property. That function receives the response data and combines two elements to form the value. -- A `default_value` property provides a value that will be used if the provided `path` expression or `generate` function resolve to a null value. - -The result of applying this output schema to the example JSON response is: - -```php -[ - zip_code => '17057', - city_state => 'Middletown, PA', -] -``` +### request_method: string -## Collection example +The `request_method` property defines the HTTP request method used by the query. By default, it is `'GET'`. -An example of collection JSON can be found in the [Art block example](https://github.com/Automattic/remote-data-blocks/blob/trunk/example/blocks/art-block/art-block.php). That API returns (in part): +### request_headers: array|callable -```json -{ - "preference": null, - "pagination": { - "total": 183, - "limit": 10, - "offset": 0, - "total_pages": 19, - "current_page": 1 - }, - "data": [ - { - "_score": 155.49371, - "thumbnail": { - "alt_text": "Color pastel drawing of ballerinas in tutus on stage, watched by audience.", - "width": 3000, - "lqip": "data:image/gif;base64,R0lGODlhCgAFAPUAADtMRVJPRFlOQlBNSFFNSEVURU1USldSS1dSTVRXTV9ZTldVUl1ZU2hbTVdkU19kVV5tX2FkUGFjVWVoVGhoVGZhW29lXGVtXG1rWmlpXW5tXmZxX3VxX1toZG5oYG5uZ3ZsY3BqZGN1a3RxYnFyZXRxZntxan19bnl9cnh7dX57doJ/dpGEeJKOhaCUjKebk6yflsGupQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAAAKAAUAAAYuQIjoQuGQTqhOyrEZYSQJA6AweURYrxIoxAhoMp9VywWLmRYqj6BxQFQshIEiCAA7", - "height": 1502 - }, - "api_model": "artworks", - "is_boosted": true, - "api_link": "https://api.artic.edu/api/v1/artworks/61603", - "id": 61603, - "title": "Ballet at the Paris Opéra", - "timestamp": "2025-01-14T22:26:21-06:00" - }, - { - "_score": 152.35487, - "thumbnail": { - "alt_text": "Impressionist painting of woman wearing green dress trying on hats.", - "width": 5003, - "lqip": "data:image/gif;base64,R0lGODlhBgAFAPQAAEMtIk40KE83KlhHLVxELlNPN1hLMVJOP19UN1dYM1lUOVpUP2dAIWlKKHZKKXZLKWRNPGpbMGpaNGtaOkxUTF9dRlJaS15YSV5kUnZpRH12W4ZkM49uRI52VQAAAAAAACH5BAAAAAAALAAAAAAGAAUAAAUY4AUtFWZxHZIdExFEybAJQGE00sNQmqOEADs=", - "height": 4543 - }, - "api_model": "artworks", - "is_boosted": true, - "api_link": "https://api.artic.edu/api/v1/artworks/14572", - "id": 14572, - "title": "The Millinery Shop", - "timestamp": "2025-01-14T23:26:12-06:00" - } - ], - "info": { - "license_text": "The `description` field in this response is licensed under a Creative Commons Attribution 4.0 Generic License (CC-By) and the Terms and Conditions of artic.edu. All other data in this response is licensed under a Creative Commons Zero (CC0) 1.0 designation and the Terms and Conditions of artic.edu.", - "license_links": [ - "https://creativecommons.org/publicdomain/zero/1.0/", - "https://www.artic.edu/terms" - ], - "version": "1.10" - }, - "config": { - "iiif_url": "https://www.artic.edu/iiif/2", - "website_url": "http://www.artic.edu" - } -} -``` +The `request_headers` property defines the request headers for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will use the request headers defined by the data source. -An output schema can be defined as: +### Example ```php -'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Art Title', - 'type' => 'string', - ], - ], -], +'request_headers' => function( array $input_variables ) use ( $data_source ): array { + return array_merge( + $data_source->get_request_headers(), + [ 'X-Foo' => $input_variables['foo'] ] + ); +}, ``` -- The `is_collection` property is set to `true` to indicate that the output represents a collection of entities. -- A top-level `path` expression (`$.data[*]`) indicates that the collection is contained in the `data` property of the response. -- The `type` property defines two fields: `id` and `title`. - - Note that the nested type definitions do not provide a `path` expression. When omitted, the plugin will use the slug as the expected path. This is a shorthand for the following output schema with explicit `path` expressions: +### cache_key_request_headers: array -```php -'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Art Title', - 'path' => '$.title', - 'type' => 'string', - ], - ], -], +A static list of additional request header names whose values will be included in the object cache key for this query. `Authorization` and `Cache-Control` are always included by default, and duplicate names are removed case-insensitively. A configured header that is absent from a request is ignored. + +```php +'cache_key_request_headers' => [ 'X-Request-Scope' ], ``` -We can enhance the output schema with additional fields and options: +**Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. + +If you implement `HttpQueryInterface` directly, implement `get_cache_key_request_headers()`. Return an empty array to use only the built-in `Authorization` and `Cache-Control` defaults, or return additional header names for that query: ```php -'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Art Title', - 'format' => function ( string $value ): string { - return ucfirst( $value ); - }, - 'type' => 'string', - ], - 'thumbnail_image_alt' => [ - 'name' => 'Thumbnail alt text', - 'path' => '$.thumbnail.alt_text', - 'type' => 'image_alt', - ], - 'thumbnail_image_url' => [ - 'name' => 'Thumbnail', - 'path' => '$.thumbnail.lqip', - 'type' => 'image_url', - ], - ], -], +public function get_cache_key_request_headers(): array { + return [ 'X-Api-Key' ]; +} ``` -The `format` property allows you to define a callable that will be applied to the value before it is returned. +### request_body: array|callable -Applying this output schema to the response JSON would result in the following output: +The `request_body` property defines the request body for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will not have a request body. + +### cache_ttl: int|null|callable + +The `cache_ttl` property defines how long the query response should be cached in seconds. It can be an integer, a callable function that returns an integer, or `null`. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). + +A value of `-1` indicates the query should not be cached. A value of `null` indicates the default TTL should be used (300 seconds). If omitted, the default TTL is used. + +Remote data blocks utilize the WordPress object cache (`wp_cache_get()` / `wp_cache_set()`) for response caching. Ensure that your platform provides or installs a persistent object cache plugin so that this value is respected. If you do not have a peristent object cache, this property will be ignored and responses will only be cached in-memory. We do not recommend running the Remote Data Blocks plugin in this configuration. + +Note that error responses are cached for 30 seconds to avoid overwhelming the remote data source with repeated requests under error conditions. Additionally, a small random jitter is added to the cache TTL to avoid cache stampedes. + +#### Example ```php -[ - [ - 'id' => 61603, - 'title' => 'Ballet at the Paris Opéra', - 'thumbnail_image_alt' => 'Color pastel drawing of ballerinas in tutus on stage, watched by audience.', - 'thumbnail_image_url' => 'data:image/gif;base64,R0lGODlhCgAFAPUAADtMRVJPRFlOQlBNSFFNSEVURU1USldSS1dSTVRXTV9ZTldVUl1ZU2hbTVdkU19kVV5tX2FkUGFjVWVoVGhoVGZhW29lXGVtXG1rWmlpXW5tXmZxX3VxX1toZG5oYG5uZ3ZsY3BqZGN1a3RxYnFyZXRxZntxan19bnl9cnh7dX57doJ/dpGEeJKOhaCUjKebk6yflsGupQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACH5BAAAAAAALAAAAAAKAAUAAAYuQIjoQuGQTqhOyrEZYSQJA6AweURYrxIoxAhoMp9VywWLmRYqj6BxQFQshIEiCAA7', - ], - [ - 'id' => 14572, - 'title' => 'The Millinery Shop', - 'thumbnail_image_alt' => 'Impressionist painting of woman wearing green dress trying on hats.', - 'thumbnail_image_url' => 'data:image/gif;base64,R0lGODlhBgAFAPQAAEMtIk40KE83KlhHLVxELlNPN1hLMVJOP19UN1dYM1lUOVpUP2dAIWlKKHZKKXZLKWRNPGpbMGpaNGtaOkxUTF9dRlJaS15YSV5kUnZpRH12W4ZkM49uRI52VQAAAAAAACH5BAAAAAAALAAAAAAGAAUAAAUY4AUtFWZxHZIdExFEybAJQGE00sNQmqOEADs=', - ], -] +'cache_ttl' => 3600, // Set the cache TTL to 1 hour ``` -```` -## File: docs/extending/query.md -````markdown -# Query +### image_url: string|null + +The `image_url` property defines an image URL that represents the query in the UI. If omitted, the query will use the image URL defined by the data source. -A query defines a request for data from a [data source](data-source.md). It defines input and output variables so that the Remote Data Blocks plugin knows how to interact with it. +### preprocess_response: callable -## Example +If you need to pre-process the response in some way before the output schema is applied, provide a `preprocess_response` function. The function will receive the deserialized response and an array of `$request_details` which describes the HTTP request that was just executed. The function should return an associative array that will be passed to the output schema for extraction. -Here is an example of a query that fetches Zip code data: +If you need to use an input variable in the pre-processing logic, first set it via the `request_headers` property and then access it via `$request_details['options']['headers']`. + +#### Example ```php -$zip_code_data_source = [ - 'display_name' => 'Zip Code API', - 'endpoint' => 'https://api.zippopotam.us/us/', -]; +'request_headers' => function( array $input_variables ): array { + return [ + 'X-Record-ID' => $input_variables['record_id'], + ]; +}, +'preprocess_response' => function( mixed $response_data, array $request_details ): array { + $record_id = $request_details['options']['headers']['X-Record-ID'] ?? ''; -$zip_code_query = [ - 'data_source' => $zip_code_data_source, - 'display_name' => 'Get location by Zip code', - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { - return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; - }, + return array_filter( + $response_data['data'] ?? [], + static function( mixed $record ) use ( $record_id ): bool { + return $record['id'] === $record_id; + } + ); +}, +``` + +### query_runner: QueryRunnerInterface + +By default, the query will use the default query runner, which works for almost every HTTP-powered API. Provide a custom query runner in the very rare cases where: + +- Your API does not respond with JSON or requires custom deserialization logic. +- Your API uses a non-HTTP transport. +- You want to implement highly custom processing of the response data which is not possible with the [provided filters](hooks.md). + +## GraphQL queries and mutations + +This plugin provides `GraphqlQuery` and `GraphqlMutation` classes that makes it easier to work with GraphQL APIs. + +```php +$graphql_query = [ + '__class' => 'RemoteDataBlocks\\Config\\Query\\GraphqlQuery', + 'data_source' => $graphql_data_source, + 'display_name' => 'Get a list of products', + 'graphql_query' => 'query GetProducts($first: Int) { + products(first: $first) { + nodes { + id + name + price + } + } + }', 'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', + 'first' => [ + 'name' => 'First', + 'type' => 'integer', + 'default' => 10, ], ], 'output_schema' => [ - 'is_collection' => false, // This query returns a single record. + 'is_collection' => true, + 'path' => '$.data.products.nodes[*]', 'type' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. - 'type' => 'string', + 'id' => [ + 'name' => 'ID', + 'path' => '$.id', + 'type' => 'id', ], - 'city' => [ - 'name' => 'City', - 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. + 'name' => [ + 'name' => 'Name', + 'path' => '$.name', 'type' => 'string', ], - 'state' => [ - 'name' => 'State', - 'path' => '$.places[0].state', - 'type' => 'string', + 'price' => [ + 'name' => 'Price', + 'path' => '$.price', + 'type' => 'currency_in_current_locale', ], ], ], ]; ``` -- The `endpoint` property is a callback function that constructs the query endpoint. In this case, the endpoint is constructed by appending the `zip_code` input variable to the data source endpoint. -- The `input_schema` property defines the input variables the query expects. For some queries, input variables might be used to construct a request body. In this case, the `zip_code` input variable is used to customize the query endpoint via the `endpoint` callback function. -- The `output_schema` property defines the output data that will be extracted from the API response and provided to the remote data block. The `path` property uses [JSONPath](https://jsonpath.com/) expressions to allow concise, no-code references to nested data. +### GraphQL + +The `GraphqlQuery` and `GraphqlMutation` classes extend the base query class, so they support all the properties defined above. Additionally, they have the following specific properties: + +#### graphql_query: string + +The `graphql_query` property defines the GraphQL query or mutation to execute. The variables should match the It should be a valid GraphQL query string, including any variables that the query expects. + +#### request_method: string + +The `request_method` property defines the HTTP request method used by the query or mutation. By default, it is `'POST'`. + +### Next steps + +Once you have defined your queries, you can use them to [register remote data blocks](block-registration.md). +```` + +## File: docs/concepts/index.md +````markdown +# Core concepts + +Remote Data Blocks allows you to integrate remote data into posts, pages, patterns, or anywhere else on your site where you use the block editor. This guide will help you understand the core concepts of the plugin and how they work. + +## What is a remote data block? + +A **remote data block** is a custom block that fetches, caches, and displays remote data from an external data source. For example, using this plugin, you can create a remote data block named "Shopify Product" that fetches a product from your Shopify store and displays the product's name, description, price, and image. Or, you might have a remote data block named "Conference event" that displays rows from an Airtable and displays the event's name, location, and type. + +Remote data blocks are **container blocks** that provide remote data to its inner blocks via [the block bindings API](block-bindings.md) or [inline bindings](inline-bindings.md). You retain complete control over the layout, design, and content of a remote data block and its inner blocks. You can leverage patterns to enable consistent styling and customize the block's appearance using the block editor's style settings, `theme.json`, or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates/theme) for more details. + +Remote data blocks are created and registered by this plugin and don't require custom block development. In addition, [helper blocks](helper-blocks.md) are also provided to perform specific tasks. + +## Caching + +This plugin offers a caching layer for optimal performance. It will be used if your WordPress environment configures a [persistent object cache](https://developer.wordpress.org/reference/classes/wp_object_cache/#persistent-cache-plugins). Otherwise, the plugin will utilize in-memory (per-page-load) caching. Deploying to production without a persistent object cache is not recommended. + +The default TTL for all cache objects is 5 minutes, but it can be [configured per query or request](../extending/query.md#cache_ttl-intnullcallable). Error responses are cached for 30 seconds to avoid overwhelming the remote data source under error conditions. Multiple requests for the same data within a single page load will be deduplicated even if the requests are not cacheable. + +### Cache isolation and custom request headers + +The response cache is shared across queries. When the site uses a persistent object cache, it is also shared across requests and users. Cache entries distinguish requests by their method, URI, body, and a configured list of request headers. `Authorization` and `Cache-Control` are included in that list by default, but arbitrary request headers are not included automatically. + +**Security warning:** If an API uses a custom header for authentication, authorization, tenancy, or any other value that changes the response, that header must be added to the cache key. Otherwise, requests that differ only by that header can share a cache entry. With a persistent object cache, this can cause a response fetched with one credential or security context to be returned to a request using another, potentially exposing protected remote data. + +Use each query's [`cache_key_request_headers`](../extending/query.md#cache_key_request_headers-array) configuration to add every custom header that can affect the authorized or returned data. Headers defined by a data source are not added to cache keys automatically. The built-in defaults cannot be removed. + +## Technical concepts + +If you want to understand the internals of Remote Data Blocks so that you can write code to extend its functionality, head over to the [extending guide](../extending/index.md). + +## Supported use cases + +Like WordPress, Remote Data Blocks is flexible. It can be used to enable advanced integrations with external data. + +Below, you'll find specific use cases where Remote Data Blocks shines. We are working to expand these use cases, but before you start, consider if Remote Data Blocks is the right tool for the job. + +### Remote Data Blocks is a good fit if: + +- Your remote data represents entities with a consistent schema. + - **Example:** Product data representing items of clothing with defined attributes like “Name,” “Price,” “Color,” “Size,” etc. +- You want humans to select specific entities for display within the block editor. + - **Example:** Select and display an item of clothing within a marketing post. +- You want to display arbitrary remote data based on a URL parameter and are willing to write a small amount of code. + - **Example:** Create a page and rewrite rule for /products/{product_id}/ and configure a Remote Data Block on that page to display the referenced product. +- Your presentation of remote data aligns with the capabilities of [block bindings](block-bindings.md). + - **Example:** Display an item of clothing using a core paragraph, heading, image, and button blocks. +- Your data is denormalized. + - **Example:** A row from a Google Sheet with no references to external entities. + +### Remote Data Blocks may not be a good fit if: + +- Your remote data is schema-less, or the schema changes over time. + - Queries for remote data must define a schema for their return data. Schema changes result in broken blocks. +- You want to display remote data outside the context of the block editor. + - Block bindings are only available in block content—posts, pages, or full-site editing. Using our plugin to define and resolve remote data may still provide some benefit (e.g., caching) but could require significant custom PHP code. +- Your data is normalized (and cannot be denormalized automatically by your API). + - Some APIs can denormalize data by automatically “inflating” referenced records for you. For example, data representing an item of clothing might reference a color by ID instead of a renderable string like “forest green.” If your API does not denormalize this relationship automatically, you will need to write custom code to perform additional queries and stitch the responses together. + - This can lead to a large number of API requests that your API may not tolerate. Airtable’s API, for example, imposes a rate limit of five requests per second, making multiple calls impractical. +- You have multiple remote data sources that require interaction with each other. Or, you want to implement a complex content architecture using Remote Data Blocks instead of leveraging WordPress custom post types and/or taxonomies. + - These two challenges are directly related to the issues with normalized data. If you have data sources that relate to one another, you must write custom code to query missing data and stitch them together. + - Judging complexity is difficult, but implementing large applications using Remote Data Blocks is not advisable. +- Your use case requires complex filtering of remote data or your API uses non-standard pagination. + - Our UI components for filtering and pagination are still under development. + +Over time, Remote Data Blocks will grow and improve and these guidelines will change. +```` + +## File: docs/extending/data-source.md +````markdown +# Data source + +A data source defines the basic reusable properties of an API and is used by a [query](query.md) to reduce duplicative code. It also helps define how your data source looks in the WordPress admin. + +## Built-in services + +The plugin provides built-in support for a small number of services: Airtable, Google Sheets, and Shopify. Data sources for built-in services can be configured via the plugin's settings screen and offer automatic query and block registration. They can also be configured via code using dedicated classes with simplified configuration: + +```php +$shopify_data_source = ShopifyDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => '{{ Access Token }}', + 'display_name' => '{{ Shopify Store Display Name }}', + 'store_name' => '{{ store-name.myshopify.com }}', + ], +] ); +``` + +See the [example templates](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/templates) for additional example code. Simple HTTP APIs can also be configured via the plugin's settings screen, but do not offer automatic query and block registration. + +### Load by UUID + +A data source that has been defined in the plugin settings screen can be loaded by its UUID using the `HttpDataSource::from_uuid()` static method. The UUID is provided via the actions (three-dot) menu. This approach allows you to write code to define [queries](query.md) and [register blocks](block-registration.md) to complete your integration. + +```php +$data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); + +/* Additional code to use the data source in queries and block registration */ +``` + +Unsupported data sources, as well as data sources that require customization not offered in the UI, must be defined in code. + +## Code example + +Here's an example of a data source configuration for an HTTP API: + +```php +$data_source = [ + 'display_name' => 'Example API', + 'endpoint' => 'https://api.example.com/', + 'request_headers' => [ + 'Content-Type' => 'application/json', + 'X-Api-Key' => constant( 'MY_API_KEY_CONSTANT' ), + ], +]; +``` + +And here is an example of a data source that was defined in the plugin settings screen, loaded by its UUID: -This example features a small subset of the customization available for a query; see the full documentation below for details. +```php +$data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); +``` ## Configuration -### display_name: string +### display_name: string (required) -The `display_name` property defines the query's human-friendly name. +The display name is used in the UI to identify your data source. -### data_source: array|HttpDataSourceInterface (required) +### endpoint: string (required) -The `data_source` property provides the [data source](./data-source.md) the query uses. It can be an array containing configuration (as in the example above) or an instance of a class that implements `HttpDataSourceInterface`. +This is the default or base endpoint for the data source. [Queries](query.md) that use a data source can override or append paths to its endpoint. -### endpoint: string|callable +### image_url: string -The `endpoint` property defines the query endpoint. It can be a string or a callable function that constructs the endpoint. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will use the endpoint defined by the data source. +An optional image URL can be used in the UI to help identify your data source. -#### Example +### request_headers: array -```php -'endpoint' => function( array $input_variables ) use ( $data_source ): string { - return $data_source-['endpoint'] . $input_variables['zip_code']; -}, -``` +An associative array of headers that will be sent with each HTTP request. Queries that use a data source can override or append headers. -### input_schema: array +When providing authentication credentials, take care to avoid committing them to code repositories. We strongly recommend using environment variables or secure storage. -The `input_schema` property defines the input variables expected by the query, which can be used to formulate the endpoint, the request headers, or the request body. Further specification and examples are provided in the [`input_schema` documentation](./query-input-schema.md). +**Security warning:** Defining a custom authentication, authorization, tenancy, or response-varying header on a data source does not automatically include it in cache keys. Add the header name to the [`cache_key_request_headers`](query.md#cache_key_request_headers-array) configuration of every query that uses it. Otherwise, requests with different header values can share cached responses and potentially expose protected data across requests or users when a persistent object cache is enabled. -### output_schema: array (required) +### Next steps -The `output_schema` property defines how an API response should be transformed and provided to a remote data block. Further information and examples are provided in the [`output_schema` documentation](./query-output-schema.md). +After defining a data source in code, you can use it in a [query](query.md) to define how data is retrieved. +```` -### pagination_schema: array +## File: docs/extending/hooks.md +````markdown +# Hooks -If your query supports pagination, the `pagination_schema` property defines how to extract pagination-related values from the query response. If defined, the property should be an associative array with the following structure: +Hooks are a way for one piece of code to interact/modify another piece of code at specific, pre-defined spots. -- `total_items`: A variable definition that extracts the total number of items across every page of results. -- `has_next_page`: A variable definition that extracts a boolean indicating whether there are more pages of results. Useful for APIs that do not report the total number of items. -- `cursor_next`: If your query supports cursor pagination, a variable definition that extracts the cursor for the next page of results. This output variable will also be mapped to `ui:pagination_cursor`, if present. -- `cursor_previous`: If your query supports cursor pagination, a variable definition that extracts the cursor for the previous page of results. +There are two types of hooks: Actions and Filters. To use either, you need to write a custom function known as a Callback, and then register it with a WordPress hook for a specific action or filter. -Note that one of `has_next_page` or `total_items` is required for all pagination types. +[Read more about Hooks](https://developer.wordpress.org/plugins/hooks/) -A pagination block will automatically be added to remote data blocks that support pagination. +## Actions -#### Example +Actions allow you to add data or change how WordPress operates. Actions will run at a specific point in the execution of plugin. Callback functions for an Action do not return anything back to the calling Action hook. -```php -'pagination_schema' => [ - 'total_items' => [ - 'name' => 'Total items', - 'path' => '$.pagination.totalItems', - 'type' => 'integer', - ], - 'cursor_next' => [ - 'name' => 'Next page cursor', - 'path' => '$.pagination.nextCursor', - 'type' => 'string', - ], - 'cursor_previous' => [ - 'name' => 'Previous page cursor', - 'path' => '$.pagination.previousCursor', - 'type' => 'string', - ], -], -``` +### remote_data_blocks_loaded -### request_method: string +This action fires when Remote Data Blocks is fully loaded and ready for use. Plugins that depend on Remote Data Blocks should use this hook to defer their initialization until Remote Data Blocks is fully loaded. -The `request_method` property defines the HTTP request method used by the query. By default, it is `'GET'`. +```php +function my_plugin_init() { + // Initialize your plugin that depends on Remote Data Blocks here + // All Remote Data Blocks classes and functionality are now available +} -### request_headers: array|callable +if ( defined( 'REMOTE_DATA_BLOCKS__LOADED' ) ) { + // Immediately init the plugin since remote data blocks is already loaded + my_plugin_init() +} else { + // Defer the init until the remote data block is loaded + add_action( 'remote_data_blocks_loaded', 'my_plugin_init' ); +} +``` -The `request_headers` property defines the request headers for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will use the request headers defined by the data source. +### remote_data_blocks_log -### Example +If you want to send debugging information to another source besides [Query Monitor](../troubleshooting.md#query-monitor), use the `remote_data_blocks_log` action. ```php -'request_headers' => function( array $input_variables ) use ( $data_source ): array { - return array_merge( - $data_source->get_request_headers(), - [ 'X-Foo' => $input_variables['foo'] ] - ); -}, +function custom_log( string $namespace, string $level, string $message, array $context ): void { + // Send the log to a custom destination. +} +add_action( 'remote_data_blocks_log', 'custom_log', 10, 4 ); ``` -### request_body: array|callable - -The `request_body` property defines the request body for the query. It can be an associative array or a callable function that returns an associative array. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). If omitted, the query will not have a request body. +## Filters -### cache_ttl: int|null|callable +Filters give you the ability to change data during the execution of the plugin. Callback functions for Filters will accept a variable, modify it, and return it. They are meant to work in an isolated manner, and should never have side effects such as affecting global variables and output. -The `cache_ttl` property defines how long the query response should be cached in seconds. It can be an integer, a callable function that returns an integer, or `null`. The callable function accepts an associative array of input variables (`[ $var_name => $value ]`). +### remote_data_blocks_register_example_block -A value of `-1` indicates the query should not be cached. A value of `null` indicates the default TTL should be used (300 seconds). If omitted, the default TTL is used. +Filter whether to register the included example API block ("Conference Event") (default: `true`). -Remote data blocks utilize the WordPress object cache (`wp_cache_get()` / `wp_cache_set()`) for response caching. Ensure that your platform provides or installs a persistent object cache plugin so that this value is respected. If you do not have a peristent object cache, this property will be ignored and responses will only be cached in-memory. We do not recommend running the Remote Data Blocks plugin in this configuration. +```php +add_filter( 'remote_data_blocks_register_example_block', '__return_false' ); +``` -Note that error responses are cached for 30 seconds to avoid overwhelming the remote data source with repeated requests under error conditions. Additionally, a small random jitter is added to the cache TTL to avoid cache stampedes. +### remote_data_blocks_auto_register_blocks_default -#### Example +Filter the default value of the "Auto-register blocks" option when adding a data source in the plugin settings screen (default: `true`). ```php -'cache_ttl' => 3600, // Set the cache TTL to 1 hour +add_filter( 'remote_data_blocks_auto_register_blocks_default', '__return_false' ); ``` -### image_url: string|null +### remote_data_blocks_allowed_url_schemes -The `image_url` property defines an image URL that represents the query in the UI. If omitted, the query will use the image URL defined by the data source. +Filter the allowed URL schemes for this request. Only HTTPS is allowed by default, but it might be useful to relax this restriction in local environments. -### preprocess_response: callable +```php +function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterface $query ): array { + // Modify the allowed URL schemes. + return $allowed_url_schemes; +} +add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_schemes', 10, 2 ); +``` -If you need to pre-process the response in some way before the output variables are extracted, provide a `preprocess_response` function. The function will receive the deserialized response. +### remote_data_blocks_pagination_query_var_name -#### Example +Filter the query variable name used for pagination (default: `rdb-pagination`). ```php -'preprocess_response' => function( mixed $response_data, array $input_variables ): array { - $some_computed_property = compute_property( $response_data['foo']['bar'] ?? '' ); - - return array_merge( - $response_data, - [ 'computed_property' => $some_computed_property ] - ); -}, +function custom_pagination_query_var_name(): string { + return 'paginate'; +} +add_filter( 'remote_data_blocks_pagination_query_var_name', 'custom_pagination_query_var_name', 10, 0 ); ``` -### query_runner: QueryRunnerInterface +### remote_data_blocks_request_details -By default, the query will use the default query runner, which works for almost every HTTP-powered API. Provide a custom query runner in the very rare cases where: +Filter the request details (method, options, url) before the HTTP request is dispatched. -- Your API does not respond with JSON or requires custom deserialization logic. -- Your API uses a non-HTTP transport. -- You want to implement highly custom processing of the response data which is not possible with the [provided filters](hooks.md). +```php +function custom_request_details( array $request_details, HttpQueryInterface $query, array $input_variables ): array { + // Modify the request details. + return $request_details; +} +add_filter( 'remote_data_blocks_request_details', 'custom_request_details', 10, 3 ); +``` -## GraphQL queries and mutations +### remote_data_blocks_query_input_variables -This plugin provides `GraphqlQuery` and `GraphqlMutation` classes that makes it easier to work with GraphQL APIs. +Filter the query input variables prior to query execution. This filter is useful for modifying the input variables for the current page-load, e.g., by pulling in data from query variables or other context. See [Overrides](overrides.md) for more information. ```php -$graphql_query = [ - '__class' => 'RemoteDataBlocks\\Config\\Query\\GraphqlQuery', - 'data_source' => $graphql_data_source, - 'display_name' => 'Get a list of products', - 'graphql_query' => 'query GetProducts($first: Int) { - products(first: $first) { - nodes { - id - name - price - } +add_filter( 'remote_data_blocks_query_input_variables', function ( array $input_variables, array $enabled_overrides, string $block_name, array $block_context ): array { + if ( true === in_array( 'my_override', $enabled_overrides, true ) ) { + $override_value = get_query_var( 'override_id' ); + + if ( ! empty( $override_value ) ) { + $input_variables['id'] = $override_value; } - }', - 'input_schema' => [ - 'first' => [ - 'name' => 'First', - 'type' => 'integer', - 'default' => 10, - ], - ], - 'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data.products.nodes[*]', - 'type' => [ - 'id' => [ - 'name' => 'ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'name' => [ - 'name' => 'Name', - 'path' => '$.name', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'Price', - 'path' => '$.price', - 'type' => 'currency_in_current_locale', - ], - ], - ], -]; + } + + return $input_variables; +}, 10, 4 ); ``` -### Configuration +Keep in mind that modifying query input variables will affect the object cache key used for query execution. This could result in a cache miss. + +### remote_data_blocks_query_response -The `GraphqlQuery` and `GraphqlMutation` classes extend the base query class, so they support all the properties defined above. Additionally, they have the following specific properties: +Filter the query response just after query execution. This filter is useful for modifying the query response for the current page-load, e.g., by pulling in data from query variables or other context. See [Overrides](overrides.md) for more information. -#### graphql_query: string +```php +add_filter( 'remote_data_blocks_query_response', function ( array $query_response, array $enabled_overrides, string $block_name, array $block_context ): array { + if ( true === in_array( 'alternate_date_format', $enabled_overrides, true ) ) { + $query_response['results'] = array_map( function ( array $result ) { + $date = new DateTime( $result['date'] ); + $result['date'] = $date->format( 'Y F d' ); + return $result; + }, $query_response['results'] ); + } -The `graphql_query` property defines the GraphQL query or mutation to execute. The variables should match the It should be a valid GraphQL query string, including any variables that the query expects. + return $input_variables; +}, 10, 4 ); +``` -#### request_method: string +The result of this filter is not cached, and will run for every block binding. -The `request_method` property defines the HTTP request method used by the query or mutation. By default, it is `'POST'`. +### remote_data_blocks_query_response_metadata + +Filter the query response metadata, which are available as targets for inline bindings. In most cases, it is better to provide a custom query class and override the `get_response_metadata` method, but this filter is available in case that is not possible. + +```php +function custom_query_response_metadata( array $metadata, HttpQueryInterface $query, array $input_variables ): array { + // Modify the response metadata. + return $metadata; +} +add_filter( 'remote_data_blocks_query_response_metadata', 'custom_query_response_metadata', 10, 3 ); +``` ```` diff --git a/example/blocks/github-markdown-block/github-markdown-block.php b/example/blocks/github-markdown-block/github-markdown-block.php index feb341099..bddc7e479 100644 --- a/example/blocks/github-markdown-block/github-markdown-block.php +++ b/example/blocks/github-markdown-block/github-markdown-block.php @@ -41,6 +41,7 @@ function register_github_markdown_remote_data_block(): void { $get_file_as_html_query = [ 'display_name' => 'Get GitHub Markdown file as HTML', 'data_source' => $github_data_source, + 'cache_key_request_headers' => [ 'Accept' ], // Provide a callable (closure) to dynamically generate the endpoint using // variables in the outer scope and the input variables. 'endpoint' => function ( array $input_variables ) use ( $repo_owner, $repo_name, $repo_ref ): string { diff --git a/example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php b/example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php index 7751df672..1d4ef6f5f 100644 --- a/example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php +++ b/example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php @@ -22,6 +22,11 @@ function register_basic_rest_api_remote_data_block_from_uuid(): void { // Get item query: Fetch one record by ID. $get_item_query = [ 'data_source' => $api_data_source, + 'cache_key_request_headers' => [ + // TODO: Include every custom header from the UI-configured data source that + // can affect authentication, authorization, tenancy, or the returned data. + // 'X-API-Key', + ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { @@ -70,6 +75,11 @@ function register_basic_rest_api_remote_data_block_from_uuid(): void { // List items query: Fetch multiple records with pagination and search. $list_items_query = [ 'data_source' => $api_data_source, + 'cache_key_request_headers' => [ + // TODO: Include every custom header from the UI-configured data source that + // can affect authentication, authorization, tenancy, or the returned data. + // 'X-API-Key', + ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { diff --git a/example/templates/rest-api-block/rest-api-block.php b/example/templates/rest-api-block/rest-api-block.php index d05883ce7..c116323ad 100644 --- a/example/templates/rest-api-block/rest-api-block.php +++ b/example/templates/rest-api-block/rest-api-block.php @@ -20,6 +20,9 @@ function register_basic_rest_api_remote_data_block(): void { $get_item_query = [ 'display_name' => 'Get item by ID', 'data_source' => $api_data_source, + // Include every custom request header above that can affect authentication, + // authorization, tenancy, or the returned data. + 'cache_key_request_headers' => [ 'X-API-Key' ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { @@ -79,6 +82,9 @@ function register_basic_rest_api_remote_data_block(): void { $list_items_query = [ 'display_name' => 'List items', 'data_source' => $api_data_source, + // Include every custom request header above that can affect authentication, + // authorization, tenancy, or the returned data. + 'cache_key_request_headers' => [ 'X-API-Key' ], // Provide a callable (closure) to dynamically generate the endpoint using // the base endpoint from the data source and the input variables. 'endpoint' => function ( array $input_variables ) use ( $api_data_source ): string { diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 3e6c6df82..6e0c373e2 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -8,7 +8,6 @@ use RemoteDataBlocks\Editor\DataBinding\Pagination; use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\HttpClient\HttpClient; -use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use WP_Error; @@ -97,7 +96,7 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $origin = sprintf( '%s://%s%s%s%s', $scheme, $user, $pass, $host, $port ); $cache_headers = [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => $cache_key_request_headers, + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => $cache_key_request_headers, ]; if ( intval( $cache_ttl ) > 0 ) { $cache_headers[ RdbCacheStrategy::CACHE_TTL_REQUEST_HEADER ] = $cache_ttl; diff --git a/inc/HttpClient/CacheKeyRequestHeaders.php b/inc/HttpClient/CacheKeyRequestHeaders.php index e97165230..35a331db4 100644 --- a/inc/HttpClient/CacheKeyRequestHeaders.php +++ b/inc/HttpClient/CacheKeyRequestHeaders.php @@ -12,19 +12,22 @@ final class CacheKeyRequestHeaders { * @return array Merged request header names. */ public static function merge( array $headers ): array { - # Start with DEFAULT_HEADERS + $merged_headers = self::DEFAULT_HEADERS; $seen_headers = array_fill_keys( array_map( 'strtolower', self::DEFAULT_HEADERS ), true ); - # Add extra headers, skipping any duplicates (case-insensitive) - $seen_headers = array_merge( - $seen_headers, - array_fill_keys( array_map( 'strtolower', $headers ), true ) - ); + foreach ( $headers as $header ) { + $normalized_header = strtolower( $header ); + if ( isset( $seen_headers[ $normalized_header ] ) ) { + continue; + } + + $seen_headers[ $normalized_header ] = true; + $merged_headers[] = $header; + } - # Return the unique header keys - return array_keys( $seen_headers ); + return $merged_headers; } } diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index 88411f4bd..ab9612d06 100644 --- a/inc/HttpClient/RdbCacheMiddleware.php +++ b/inc/HttpClient/RdbCacheMiddleware.php @@ -2,7 +2,17 @@ namespace RemoteDataBlocks\HttpClient; +use Psr\Http\Message\RequestInterface; + class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware { + public function __invoke( callable $handler ): callable { + $handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) { + return $handler( $request->withoutHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ), $options ); + }; + + return parent::__invoke( $handler_without_cache_metadata ); + } + /** * @var array */ diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index 99cde5f6d..31b9b9d3e 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -9,7 +9,7 @@ use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\HttpClient\HttpClient; -use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; +use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use RemoteDataBlocks\Tests\Mocks\MockDataSource; use RemoteDataBlocks\Tests\Mocks\MockQuery; use WP_Error; @@ -116,7 +116,7 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array $this->assertIsArray( $request_details ); $this->assertSame( [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], - $request_details['options'][ RequestOptions::HEADERS ][ RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ] ?? null + $request_details['options'][ RequestOptions::HEADERS ][ RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ] ?? null ); } diff --git a/tests/inc/Config/QueryTest.php b/tests/inc/Config/QueryTest.php index 9cb7d8647..92730903e 100644 --- a/tests/inc/Config/QueryTest.php +++ b/tests/inc/Config/QueryTest.php @@ -38,7 +38,7 @@ public function testGetRequestHeaders(): void { $this->assertSame( [ 'Content-Type' => 'application/json' ], $result ); } - public function testCacheKeyRequestHeadersMergeDefaultsAndQueryOnly(): void { + public function testCacheKeyRequestHeadersAreQueryOnly(): void { $data_source = HttpDataSource::from_array( [ 'display_name' => 'Custom API', 'endpoint' => 'https://example.com/api', @@ -54,7 +54,7 @@ public function testCacheKeyRequestHeadersMergeDefaultsAndQueryOnly(): void { $this->assertInstanceOf( HttpQuery::class, $query ); $this->assertSame( - [ 'Authorization', 'Cache-Control', 'x-api-key', 'X-Request-Scope' ], + [ 'x-api-key', 'X-Request-Scope' ], $query->get_cache_key_request_headers() ); } diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index 626dc5cb9..76769565d 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -262,6 +262,26 @@ public function testRepeatedPostRequestsWithDifferentAuthorizationHeaderResultsI $this->assertEquals( 0, $this->mock_handler->count(), 'The mock handler should be empty after the second request' ); } + public function testUnconfiguredCustomHeaderWithDifferentValuesResultsInCacheHit(): void { + $this->mock_handler->append( + new Response( 200, [], 'Cached Response' ), + new Response( 200, [], 'Uncached Response' ) + ); + + $first_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'X-Tenant-ID' => 'first-tenant' ], + ], $this->client ); + $second_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ 'X-Tenant-ID' => 'second-tenant' ], + ], $this->client ); + + $this->assertSame( 'Cached Response', (string) $first_response->getBody() ); + $this->assertSame( RdbCacheMiddleware::HEADER_CACHE_MISS, $first_response->getHeaderLine( RdbCacheMiddleware::HEADER_CACHE_INFO ) ); + $this->assertSame( 'Cached Response', (string) $second_response->getBody() ); + $this->assertSame( RdbCacheMiddleware::HEADER_CACHE_HIT, $second_response->getHeaderLine( RdbCacheMiddleware::HEADER_CACHE_INFO ) ); + $this->assertSame( 1, $this->mock_handler->count(), 'Only one response should be consumed when an unconfigured header value differs' ); + } + public function testConfiguredCustomHeaderWithDifferentValuesResultsInCacheMiss(): void { $this->mock_handler->append( new Response( 200, [], 'First Response' ), @@ -270,13 +290,13 @@ public function testConfiguredCustomHeaderWithDifferentValuesResultsInCacheMiss( $first_response = $this->http_client->request( 'GET', '/test', [ 'headers' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], 'X-Api-Key' => 'first-api-key', ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ 'headers' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], 'X-Api-Key' => 'second-api-key', ], ], $this->client ); @@ -296,13 +316,13 @@ public function testConfiguredCustomHeaderNameIsCaseInsensitive(): void { $first_response = $this->http_client->request( 'GET', '/test', [ 'headers' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'x-api-key' => 'first-api-key', ], ], $this->client ); $second_response = $this->http_client->request( 'GET', '/test', [ 'headers' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'x-api-key' => 'second-api-key', ], ], $this->client ); @@ -323,21 +343,21 @@ public function testCacheKeyRequestHeaderMetadataIsNotSentToRequestHandler(): vo $this->http_client->request( 'GET', '/test', [ 'headers' => [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'X-Api-Key' => 'secret', ], ], $client ); $this->assertCount( 1, $transactions ); $this->assertSame( 'secret', $transactions[0]['request']->getHeaderLine( 'X-Api-Key' ) ); - $this->assertFalse( $transactions[0]['request']->hasHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); + $this->assertFalse( $transactions[0]['request']->hasHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) ); } public function testCacheKeyRequestHeaderMetadataIsNotStoredInCacheEntry(): void { $storage = new VolatileRuntimeStorage(); $strategy = new RdbCacheStrategy( $storage ); $request = new Request( 'GET', 'https://example.com/test', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'X-Api-Key' => 'secret', ] ); @@ -345,7 +365,7 @@ public function testCacheKeyRequestHeaderMetadataIsNotStoredInCacheEntry(): void $cache_entry = $strategy->fetch( $request ); $this->assertInstanceOf( CacheEntry::class, $cache_entry ); - $this->assertFalse( $cache_entry->getOriginalRequest()->hasHeader( RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER ) ); + $this->assertFalse( $cache_entry->getOriginalRequest()->hasHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) ); } public function testRepeatedPostRequestsWithDifferentBodyResultsInCacheMiss(): void { diff --git a/tests/inc/HttpClient/RdbLogMiddlewareTest.php b/tests/inc/HttpClient/RdbLogMiddlewareTest.php index 5156709ed..8c909d9b2 100644 --- a/tests/inc/HttpClient/RdbLogMiddlewareTest.php +++ b/tests/inc/HttpClient/RdbLogMiddlewareTest.php @@ -6,7 +6,7 @@ use GuzzleHttp\Psr7\Request; use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\HttpClient\RdbCacheMiddleware; +use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use RemoteDataBlocks\HttpClient\RdbLogMiddleware; use RemoteDataBlocks\Tests\Mocks\MockWordPressFunctions; @@ -25,7 +25,7 @@ public function testConfiguredHeaderValuesProduceDifferentLoggedCacheKeys(): voi $first_options = []; $log_handler( new Request( 'GET', 'https://example.com/data', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'X-Api-Key' => 'first-api-key', ] ), $first_options @@ -34,7 +34,7 @@ public function testConfiguredHeaderValuesProduceDifferentLoggedCacheKeys(): voi $second_options = []; $log_handler( new Request( 'GET', 'https://example.com/data', [ - RdbCacheMiddleware::CACHE_KEY_REQUEST_HEADERS_HEADER => [ 'X-Api-Key' ], + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], 'X-Api-Key' => 'second-api-key', ] ), $second_options From b94db2ff595c9ed45bb0ad9480f9a6b9cb232c6c Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 16:12:51 -0500 Subject: [PATCH 13/18] Regenerate AI docs after example updates --- docs/for-ai.md | 426 ++++++++++++++++++++++++------------------------- 1 file changed, 213 insertions(+), 213 deletions(-) diff --git a/docs/for-ai.md b/docs/for-ai.md index e2d117673..5b2583275 100644 --- a/docs/for-ai.md +++ b/docs/for-ai.md @@ -1178,6 +1178,155 @@ alwaysApply: true ```` +## File: example/blocks/art-block/art-block.php +````php + 'Art Institute of Chicago', + 'endpoint' => 'https://api.artic.edu/api/v1/artworks', + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], + ]; + + $get_art_query = [ + 'display_name' => 'Get artwork by ID', + 'data_source' => $aic_data_source, + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $endpoint = add_query_arg( [ + 'fields' => 'id,title,image_id,artist_title', + ], $aic_data_source['endpoint'] ); + + if ( is_array( $input_variables['id'] ) ) { + $ids = implode( ',', $input_variables['id'] ); + } else { + $ids = $input_variables['id']; + } + + if ( ! empty( $ids ) ) { + return add_query_arg( [ 'ids' => $ids ], $endpoint ); + } + + return $endpoint; + }, + 'input_schema' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id:list', // This type indicates that the input can be a single ID or a list of IDs. + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + 'path' => '$.id', + ], + 'artist_title' => [ + 'name' => 'Artist Title', + 'type' => 'string', + 'path' => '$.artist_title', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'title', + 'path' => '$.title', + ], + 'image_url' => [ + 'name' => 'Image URL', + // Instead of a `path`, we provide a `generate` function to create the + // image URL. The `$data` parameter contains the data returned from the + // API at this "level" (e.g., after the root `path` has been applied). + 'generate' => static function ( $data ): string { + return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; + }, + 'type' => 'image_url', + ], + ], + ], + ]; + + $search_art_query = [ + 'display_name' => 'Search artworks', + 'data_source' => $aic_data_source, + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $endpoint = $aic_data_source['endpoint'] . '/search'; + $search_terms = $input_variables['search'] ?? ''; + + // Do not include the `q` parameter if the search terms are empty. + // Otherwise, this will result in an error from the API. + if ( ! empty( $search_terms ) ) { + $endpoint = add_query_arg( [ 'q' => $search_terms ], $endpoint ); + } + + return add_query_arg( [ + 'limit' => $input_variables['limit'], + 'fields' => 'id,title,image_id,artist_title', + 'page' => $input_variables['page'], + ], $endpoint ); + }, + 'input_schema' => [ + 'search' => [ + 'name' => 'Search terms', + 'type' => 'ui:search_input', + ], + 'limit' => [ + 'default_value' => 10, + 'name' => 'Items per page', + 'type' => 'ui:pagination_per_page', + ], + 'page' => [ + 'default_value' => 1, + 'name' => 'Starting page', + 'type' => 'ui:pagination_page', + ], + ], + // Reuse the output schema from `$get_art_query`. + 'output_schema' => $get_art_query['output_schema'], + 'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.pagination.total', + 'type' => 'integer', + ], + ], + ]; + + register_remote_data_block( [ + 'title' => 'Art Institute of Chicago', + 'icon' => 'art', + 'render_query' => [ + 'query' => $get_art_query, + ], + 'selection_queries' => [ + [ + 'query' => $search_art_query, + 'type' => 'search', + ], + ], + ] ); +} +add_action( 'init', __NAMESPACE__ . '\\register_art_remote_data_block' ); +```` + ## File: example/blocks/book-block/patterns/book-pattern.html ````html @@ -1896,6 +2045,70 @@ function register_weather_remote_data_block(): void { add_action( 'init', __NAMESPACE__ . '\\register_weather_remote_data_block' ); ```` +## File: example/blocks/zip-code-block/zip-code-block.php +````php + 'Zip Code', + 'endpoint' => 'https://api.zippopotam.us/us/', + ]; + + $zip_code_query = [ + 'data_source' => $zip_code_data_source, + 'display_name' => 'Get location by Zip code', + // Provide a callable (closure) to dynamically generate the endpoint using + // the base endpoint from the data source and the input variables. + 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { + return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, // This query returns a single record. + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ]; + + register_remote_data_block( [ + 'title' => 'Zip Code', + 'render_query' => [ + 'query' => $zip_code_query, + ], + ] ); +} +add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); +```` + ## File: example/templates/airtable-block/airtable-block.php ````php 'Art Institute of Chicago', - 'endpoint' => 'https://api.artic.edu/api/v1/artworks', - 'request_headers' => [ - 'Content-Type' => 'application/json', - ], - ]; - - $get_art_query = [ - 'display_name' => 'Get artwork by ID', - 'data_source' => $aic_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $endpoint = add_query_arg( [ - 'fields' => 'id,title,image_id,artist_title', - ], $aic_data_source['endpoint'] ); - - if ( is_array( $input_variables['id'] ) ) { - $ids = implode( ',', $input_variables['id'] ); - } else { - $ids = $input_variables['id']; - } - - if ( ! empty( $ids ) ) { - return add_query_arg( [ 'ids' => $ids ], $endpoint ); - } - - return $endpoint; - }, - 'input_schema' => [ - 'id' => [ - 'name' => 'Art ID', - 'type' => 'id:list', // This type indicates that the input can be a single ID or a list of IDs. - ], - ], - 'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'type' => 'id', - 'path' => '$.id', - ], - 'artist_title' => [ - 'name' => 'Artist Title', - 'type' => 'string', - 'path' => '$.artist_title', - ], - 'title' => [ - 'name' => 'Title', - 'type' => 'title', - 'path' => '$.title', - ], - 'image_url' => [ - 'name' => 'Image URL', - // Instead of a `path`, we provide a `generate` function to create the - // image URL. The `$data` parameter contains the data returned from the - // API at this "level" (e.g., after the root `path` has been applied). - 'generate' => static function ( $data ): string { - return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; - }, - 'type' => 'image_url', - ], - ], - ], - ]; - - $search_art_query = [ - 'display_name' => 'Search artworks', - 'data_source' => $aic_data_source, - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { - $endpoint = $aic_data_source['endpoint'] . '/search'; - $search_terms = $input_variables['search'] ?? ''; - - // Do not include the `q` parameter if the search terms are empty. - // Otherwise, this will result in an error from the API. - if ( ! empty( $search_terms ) ) { - $endpoint = add_query_arg( [ 'q' => $search_terms ], $endpoint ); - } - - return add_query_arg( [ - 'limit' => $input_variables['limit'], - 'fields' => 'id,title,image_id,artist_title', - 'page' => $input_variables['page'], - ], $endpoint ); - }, - 'input_schema' => [ - 'search' => [ - 'name' => 'Search terms', - 'type' => 'ui:search_input', - ], - 'limit' => [ - 'default_value' => 10, - 'name' => 'Items per page', - 'type' => 'ui:pagination_per_page', - ], - 'page' => [ - 'default_value' => 1, - 'name' => 'Starting page', - 'type' => 'ui:pagination_page', - ], - ], - // Reuse the output schema from `$get_art_query`. - 'output_schema' => $get_art_query['output_schema'], - 'pagination_schema' => [ - 'total_items' => [ - 'name' => 'Total items', - 'path' => '$.pagination.total', - 'type' => 'integer', - ], - ], - ]; - - register_remote_data_block( [ - 'title' => 'Art Institute of Chicago', - 'icon' => 'art', - 'render_query' => [ - 'query' => $get_art_query, - ], - 'selection_queries' => [ - [ - 'query' => $search_art_query, - 'type' => 'search', - ], - ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_art_remote_data_block' ); -```` - ## File: example/blocks/github-markdown-block/github-markdown-block.php ````php 'Zip Code', - 'endpoint' => 'https://api.zippopotam.us/us/', - ]; - - $zip_code_query = [ - 'data_source' => $zip_code_data_source, - 'display_name' => 'Get location by Zip code', - // Provide a callable (closure) to dynamically generate the endpoint using - // the base endpoint from the data source and the input variables. - 'endpoint' => function ( array $input_variables ) use ( $zip_code_data_source ): string { - return $zip_code_data_source['endpoint'] . $input_variables['zip_code']; - }, - 'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', - ], - ], - 'output_schema' => [ - 'is_collection' => false, // This query returns a single record. - 'type' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. - 'type' => 'string', - ], - 'city' => [ - 'name' => 'City', - 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. - 'type' => 'string', - ], - 'state' => [ - 'name' => 'State', - 'path' => '$.places[0].state', - 'type' => 'string', - ], - ], - ], - ]; - - register_remote_data_block( [ - 'title' => 'Zip Code', - 'render_query' => [ - 'query' => $zip_code_query, - ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); -```` - ## File: example/templates/airtable-map-block/src/leaflet-map/render.php ````php Date: Tue, 18 Aug 2026 16:47:52 -0500 Subject: [PATCH 14/18] Own the remote data cache strategy --- inc/HttpClient/RdbCacheStrategy.php | 72 +++++++++++++++++-------- tests/inc/HttpClient/HttpClientTest.php | 35 ++++++++++++ 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index de0c9c8f1..5bcbef9fe 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -5,29 +5,40 @@ use DateTime; use Kevinrob\GuzzleCache\CacheEntry; use Kevinrob\GuzzleCache\CacheMiddleware; -use Kevinrob\GuzzleCache\KeyValueHttpHeader; use Kevinrob\GuzzleCache\Storage\CacheStorageInterface; use Kevinrob\GuzzleCache\Storage\WordPressObjectCacheStorage; -use Kevinrob\GuzzleCache\Strategy\GreedyCacheStrategy; +use Kevinrob\GuzzleCache\Strategy\CacheStrategyInterface; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; use function wp_rand; -class RdbCacheStrategy extends GreedyCacheStrategy { +class RdbCacheStrategy implements CacheStrategyInterface { public const CACHE_AGE_RESPONSE_HEADER = 'Age'; public const CACHE_STATUS_RESPONSE_HEADER = CacheMiddleware::HEADER_CACHE_INFO; - public const CACHE_TTL_REQUEST_HEADER = GreedyCacheStrategy::HEADER_TTL; + public const CACHE_TTL_REQUEST_HEADER = 'X-Kevinrob-GuzzleCache-TTL'; public const CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks'; private const ERROR_CACHE_TTL_IN_SECONDS = 30; // 30 seconds for error responses private const FALLBACK_CACHE_TTL_IN_SECONDS = 300; // 5 minutes for success responses + private const STATUS_ACCEPTED = [ + 200 => true, + 203 => true, + 204 => true, + 300 => true, + 301 => true, + 404 => true, + 405 => true, + 410 => true, + 414 => true, + 418 => true, + 501 => true, + ]; + + private CacheStorageInterface $storage; public function __construct( ?CacheStorageInterface $storage = null ) { - parent::__construct( - $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ), - self::FALLBACK_CACHE_TTL_IN_SECONDS - ); + $this->storage = $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ); } public static function get_object_cache_key_from_request( RequestInterface $request ): string { @@ -56,20 +67,42 @@ public static function get_object_cache_key_from_request( RequestInterface $requ return sprintf( 'http-client:%s', $input_hash ); } - /** @psalm-suppress ParamNameMismatch reason: parent is camelCase, but we want snake_case */ - protected function getCacheKey( RequestInterface $request, ?KeyValueHttpHeader $_vary_headers = null ): string { - return self::get_object_cache_key_from_request( $request ); + public function fetch( RequestInterface $request ): ?CacheEntry { + return $this->storage->fetch( self::get_object_cache_key_from_request( $request ) ); + } + + public function cache( RequestInterface $request, ResponseInterface $response ): bool { + $warning_message = sprintf( + '%d - "%s" "%s"', + 299, + 'Cached although the response headers indicate not to do it!', + ( new DateTime() )->format( DateTime::RFC1123 ) + ); + $response = $response->withAddedHeader( 'Warning', $warning_message ); + $cache_object = $this->get_cache_object( $request, $response ); + + return $this->storage->save( + self::get_object_cache_key_from_request( $request ), + $cache_object + ); } - protected function getCacheObject( RequestInterface $request, ResponseInterface $response ): ?CacheEntry { - // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $ttl = $this->defaultTtl; - if ( $request->hasHeader( static::HEADER_TTL ) ) { - $ttl_header_values = $request->getHeader( static::HEADER_TTL ); + public function update( RequestInterface $request, ResponseInterface $response ): bool { + return $this->cache( $request, $response ); + } + + public function delete( RequestInterface $request ): bool { + return $this->storage->delete( self::get_object_cache_key_from_request( $request ) ); + } + + private function get_cache_object( RequestInterface $request, ResponseInterface $response ): CacheEntry { + $ttl = self::FALLBACK_CACHE_TTL_IN_SECONDS; + if ( $request->hasHeader( self::CACHE_TTL_REQUEST_HEADER ) ) { + $ttl_header_values = $request->getHeader( self::CACHE_TTL_REQUEST_HEADER ); $ttl = (int) reset( $ttl_header_values ); } - if ( ! array_key_exists( $response->getStatusCode(), $this->statusAccepted ) ) { + if ( ! isset( self::STATUS_ACCEPTED[ $response->getStatusCode() ] ) ) { // Cache it for a short time period to prevent error floods. $ttl = self::ERROR_CACHE_TTL_IN_SECONDS; } @@ -80,13 +113,10 @@ protected function getCacheObject( RequestInterface $request, ResponseInterface $jitter = intval( ceil( min( $ttl * 0.1, 20 ) ) ); $ttl = intval( $ttl ) + wp_rand( 0, $jitter ); - // Cache-key request headers are resolved per request in getCacheKey(), so - // the parent's static vary-header check does not apply here. - $response = $response->withoutHeader( 'Etag' )->withoutHeader( 'Last-Modified' ); $cache_request = $request - ->withoutHeader( static::HEADER_TTL ) + ->withoutHeader( self::CACHE_TTL_REQUEST_HEADER ) ->withoutHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ); return new CacheEntry( $cache_request, $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); diff --git a/tests/inc/HttpClient/HttpClientTest.php b/tests/inc/HttpClient/HttpClientTest.php index 76769565d..0a557aa2f 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -368,6 +368,41 @@ public function testCacheKeyRequestHeaderMetadataIsNotStoredInCacheEntry(): void $this->assertFalse( $cache_entry->getOriginalRequest()->hasHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) ); } + public function testCacheStrategyUpdateReplacesCachedResponse(): void { + $strategy = new RdbCacheStrategy( new VolatileRuntimeStorage() ); + $request = new Request( 'GET', 'https://example.com/test' ); + + $this->assertTrue( $strategy->cache( $request, new Response( 200, [], 'Initial Response' ) ) ); + $this->assertTrue( $strategy->update( $request, new Response( 200, [], 'Updated Response' ) ) ); + + $cache_entry = $strategy->fetch( $request ); + $this->assertInstanceOf( CacheEntry::class, $cache_entry ); + $this->assertSame( 'Updated Response', (string) $cache_entry->getResponse()->getBody() ); + } + + public function testCacheStrategyDeleteRemovesCachedResponse(): void { + $strategy = new RdbCacheStrategy( new VolatileRuntimeStorage() ); + $request = new Request( 'GET', 'https://example.com/test' ); + + $this->assertTrue( $strategy->cache( $request, new Response( 200 ) ) ); + $this->assertTrue( $strategy->delete( $request ) ); + $this->assertNull( $strategy->fetch( $request ) ); + } + + public function testCacheStrategyAddsWarningHeaderToCachedResponse(): void { + $strategy = new RdbCacheStrategy( new VolatileRuntimeStorage() ); + $request = new Request( 'GET', 'https://example.com/test' ); + + $this->assertTrue( $strategy->cache( $request, new Response( 200 ) ) ); + + $cache_entry = $strategy->fetch( $request ); + $this->assertInstanceOf( CacheEntry::class, $cache_entry ); + $this->assertStringContainsString( + 'Cached although the response headers indicate not to do it!', + $cache_entry->getResponse()->getHeaderLine( 'Warning' ) + ); + } + public function testRepeatedPostRequestsWithDifferentBodyResultsInCacheMiss(): void { // Set up the mock handler with two responses $this->mock_handler->append( From 52bc96e60834622f5c622006518c76939c911cee Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 16:48:24 -0500 Subject: [PATCH 15/18] Preserve HTTP logger options --- inc/HttpClient/RdbLogMiddleware.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/inc/HttpClient/RdbLogMiddleware.php b/inc/HttpClient/RdbLogMiddleware.php index bb760e58d..cf44274c8 100644 --- a/inc/HttpClient/RdbLogMiddleware.php +++ b/inc/HttpClient/RdbLogMiddleware.php @@ -27,8 +27,8 @@ public function __invoke( callable $handler ): callable { return function ( RequestInterface $request, array &$options ) use ( $handler ): PromiseInterface { return $handler( $request, $options ) ->then( - $this->handle_success( $request ), - $this->handle_failure( $request ) + $this->handle_success( $request, $options ), + $this->handle_failure( $request, $options ) ); }; } @@ -57,10 +57,10 @@ private function log( RequestInterface $request, ?ResponseInterface $response, ? /** * Returns a function which is handled when a request was rejected. */ - private function handle_failure( RequestInterface $request ): callable { - return function ( \Exception $reason ) use ( $request ) { + private function handle_failure( RequestInterface $request, array $options ): callable { + return function ( \Exception $reason ) use ( $request, $options ) { $response = ( $reason instanceof RequestException && $reason->hasResponse() === true ) ? $reason->getResponse() : null; - $this->log( $request, $response, $reason ); + $this->log( $request, $response, $reason, $options ); return Create::rejectionFor( $reason ); }; } @@ -68,9 +68,9 @@ private function handle_failure( RequestInterface $request ): callable { /** * Returns a function which is handled when a request was successful. */ - private function handle_success( RequestInterface $request ): callable { - return function ( ResponseInterface $response ) use ( $request ) { - $this->log( $request, $response, null ); + private function handle_success( RequestInterface $request, array $options ): callable { + return function ( ResponseInterface $response ) use ( $request, $options ) { + $this->log( $request, $response, null, $options ); return $response; }; } From be07ab987b0153b1e4791932759827e20c933cdd Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 17:10:16 -0500 Subject: [PATCH 16/18] Preserve custom HTTP query compatibility --- docs/extending/query.md | 10 ++- docs/for-ai.md | 10 ++- .../CacheKeyRequestHeadersAwareInterface.php | 15 ++++ inc/Config/Query/HttpQuery.php | 2 +- inc/Config/Query/HttpQueryInterface.php | 8 -- inc/Config/QueryRunner/QueryRunner.php | 9 +- tests/inc/Config/QueryRunnerTest.php | 19 ++++ tests/inc/Mocks/LegacyHttpQuery.php | 88 +++++++++++++++++++ 8 files changed, 143 insertions(+), 18 deletions(-) create mode 100644 inc/Config/Query/CacheKeyRequestHeadersAwareInterface.php create mode 100644 tests/inc/Mocks/LegacyHttpQuery.php diff --git a/docs/extending/query.md b/docs/extending/query.md index 25337f407..be621fcc6 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -149,11 +149,15 @@ A static list of additional request header names whose values will be included i **Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. -If you implement `HttpQueryInterface` directly, implement `get_cache_key_request_headers()`. Return an empty array to use only the built-in `Authorization` and `Cache-Control` defaults, or return additional header names for that query: +Queries implemented with `HttpQuery` support this configuration automatically. If you implement `HttpQueryInterface` directly, also implement the optional `CacheKeyRequestHeadersAwareInterface` to return additional header names for that query. Existing `HttpQueryInterface` implementations that do not implement the optional interface use only the built-in `Authorization` and `Cache-Control` defaults. ```php -public function get_cache_key_request_headers(): array { - return [ 'X-Api-Key' ]; +class CustomQuery implements HttpQueryInterface, CacheKeyRequestHeadersAwareInterface { + // ... + + public function get_cache_key_request_headers(): array { + return [ 'X-Api-Key' ]; + } } ``` diff --git a/docs/for-ai.md b/docs/for-ai.md index 5b2583275..40a0814ab 100644 --- a/docs/for-ai.md +++ b/docs/for-ai.md @@ -3642,11 +3642,15 @@ A static list of additional request header names whose values will be included i **Security warning:** Add every header that can affect authentication, authorization, tenancy, or the returned data, including custom headers inherited from the query's data source. Data-source request headers are not added to cache keys automatically. Omitting such a header can allow requests with different security contexts to share a cached response, potentially exposing protected data across requests and users when a persistent object cache is enabled. -If you implement `HttpQueryInterface` directly, implement `get_cache_key_request_headers()`. Return an empty array to use only the built-in `Authorization` and `Cache-Control` defaults, or return additional header names for that query: +Queries implemented with `HttpQuery` support this configuration automatically. If you implement `HttpQueryInterface` directly, also implement the optional `CacheKeyRequestHeadersAwareInterface` to return additional header names for that query. Existing `HttpQueryInterface` implementations that do not implement the optional interface use only the built-in `Authorization` and `Cache-Control` defaults. ```php -public function get_cache_key_request_headers(): array { - return [ 'X-Api-Key' ]; +class CustomQuery implements HttpQueryInterface, CacheKeyRequestHeadersAwareInterface { + // ... + + public function get_cache_key_request_headers(): array { + return [ 'X-Api-Key' ]; + } } ``` diff --git a/inc/Config/Query/CacheKeyRequestHeadersAwareInterface.php b/inc/Config/Query/CacheKeyRequestHeadersAwareInterface.php new file mode 100644 index 000000000..32fb8b406 --- /dev/null +++ b/inc/Config/Query/CacheKeyRequestHeadersAwareInterface.php @@ -0,0 +1,15 @@ + Request header names included in cache keys. + */ + public function get_cache_key_request_headers(): array; +} diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php index c0dcbd3ec..2fe657efb 100644 --- a/inc/Config/Query/HttpQuery.php +++ b/inc/Config/Query/HttpQuery.php @@ -16,7 +16,7 @@ * * This class can be used to implement most HTTP queries. */ -class HttpQuery extends ArraySerializable implements HttpQueryInterface { +class HttpQuery extends ArraySerializable implements CacheKeyRequestHeadersAwareInterface, HttpQueryInterface { /** * Execute the query with the provided input variables. Execution can be * customized by providing a custom query runner. diff --git a/inc/Config/Query/HttpQueryInterface.php b/inc/Config/Query/HttpQueryInterface.php index ccf0633c4..00e841080 100644 --- a/inc/Config/Query/HttpQueryInterface.php +++ b/inc/Config/Query/HttpQueryInterface.php @@ -13,14 +13,6 @@ interface HttpQueryInterface extends QueryInterface { public function get_data_source(): HttpDataSourceInterface; public function get_cache_ttl( array $input_variables ): null|int; - - /** - * Get the request header names whose values should be included in cache keys. - * - * @return array Request header names included in cache keys. - */ - public function get_cache_key_request_headers(): array; - public function get_endpoint( array $input_variables ): string; public function get_request_method(): string; public function get_request_headers( array $input_variables ): array|WP_Error; diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 6e0c373e2..74f7cd08a 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -4,6 +4,7 @@ use Exception; use GuzzleHttp\RequestOptions; +use RemoteDataBlocks\Config\Query\CacheKeyRequestHeadersAwareInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Editor\DataBinding\Pagination; use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; @@ -58,9 +59,11 @@ protected function get_request_details( HttpQueryInterface $query, array $input_ $body = $query->get_request_body( $input_variables ); $endpoint = $query->get_endpoint( $input_variables ); $cache_ttl = $query->get_cache_ttl( $input_variables ); - $cache_key_request_headers = CacheKeyRequestHeaders::merge( - $query->get_cache_key_request_headers() - ); + $additional_cache_key_request_headers = []; + if ( $query instanceof CacheKeyRequestHeadersAwareInterface ) { + $additional_cache_key_request_headers = $query->get_cache_key_request_headers(); + } + $cache_key_request_headers = CacheKeyRequestHeaders::merge( $additional_cache_key_request_headers ); $parsed_url = wp_parse_url( $endpoint ); if ( false === $parsed_url ) { diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index 31b9b9d3e..258e02bab 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -10,6 +10,7 @@ use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; +use RemoteDataBlocks\Tests\Mocks\LegacyHttpQuery; use RemoteDataBlocks\Tests\Mocks\MockDataSource; use RemoteDataBlocks\Tests\Mocks\MockQuery; use WP_Error; @@ -120,6 +121,24 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array ); } + public function testLegacyQueryUsesDefaultCacheKeyRequestHeaders(): void { + $data_source = MockDataSource::create(); + $this->assertInstanceOf( MockDataSource::class, $data_source ); + + $query_runner = new class($this->http_client, []) extends QueryRunner { + public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error { + return $this->get_request_details( $query, [] ); + } + }; + $request_details = $query_runner->get_request_details_for_test( new LegacyHttpQuery( $data_source ) ); + + $this->assertIsArray( $request_details ); + $this->assertSame( + [ 'Authorization', 'Cache-Control' ], + $request_details['options'][ RequestOptions::HEADERS ][ RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ] ?? null + ); + } + public static function provideInvalidEndpoints(): array { return [ [ diff --git a/tests/inc/Mocks/LegacyHttpQuery.php b/tests/inc/Mocks/LegacyHttpQuery.php new file mode 100644 index 000000000..2a2cfed17 --- /dev/null +++ b/tests/inc/Mocks/LegacyHttpQuery.php @@ -0,0 +1,88 @@ +data_source; + } + + public function get_image_url(): ?string { + return null; + } + + public function get_input_schema(): array { + return []; + } + + public function get_output_schema(): array { + return []; + } + + public function get_pagination_schema(): ?array { + return null; + } + + public function get_cache_ttl( array $input_variables ): null|int { + return null; + } + + public function get_endpoint( array $input_variables ): string { + return $this->data_source->get_endpoint(); + } + + public function get_request_method(): string { + return 'GET'; + } + + public function get_request_headers( array $input_variables ): array|WP_Error { + return $this->data_source->get_request_headers(); + } + + public function get_request_body( array $input_variables ): array|null { + return null; + } + + public function preprocess_response( mixed $response_data, array $request_details ): mixed { + return $response_data; + } +} From 9ae57316ad368f1e2c043d9edd59f06bb1ee153b Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Tue, 18 Aug 2026 17:12:45 -0500 Subject: [PATCH 17/18] Own cache TTL request header --- inc/HttpClient/RdbCacheStrategy.php | 2 +- tests/inc/Config/QueryRunnerTest.php | 23 +++++++++++++++++++++++ tests/inc/Mocks/MockQuery.php | 1 + 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index 5bcbef9fe..08c14bb78 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -15,7 +15,7 @@ class RdbCacheStrategy implements CacheStrategyInterface { public const CACHE_AGE_RESPONSE_HEADER = 'Age'; public const CACHE_STATUS_RESPONSE_HEADER = CacheMiddleware::HEADER_CACHE_INFO; - public const CACHE_TTL_REQUEST_HEADER = 'X-Kevinrob-GuzzleCache-TTL'; + public const CACHE_TTL_REQUEST_HEADER = 'X-Remote-Data-Blocks-Cache-TTL'; public const CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER = 'X-Remote-Data-Blocks-Cache-Key-Headers'; public const WP_OBJECT_CACHE_GROUP = 'remote-data-blocks'; diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index 258e02bab..b5b76c6b5 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -121,6 +121,29 @@ public function get_request_details_for_test( HttpQueryInterface $query ): array ); } + public function testRequestDetailsUseRdbCacheTtlHeader(): void { + $data_source = MockDataSource::create(); + $this->assertInstanceOf( MockDataSource::class, $data_source ); + + $query = MockQuery::create( [ + 'cache_ttl' => 600, + 'data_source' => $data_source, + ] ); + $this->assertInstanceOf( MockQuery::class, $query ); + + $query_runner = new class($this->http_client, []) extends QueryRunner { + public function get_request_details_for_test( HttpQueryInterface $query ): array|WP_Error { + return $this->get_request_details( $query, [] ); + } + }; + $request_details = $query_runner->get_request_details_for_test( $query ); + + $this->assertIsArray( $request_details ); + $headers = $request_details['options'][ RequestOptions::HEADERS ]; + $this->assertSame( 600, $headers['X-Remote-Data-Blocks-Cache-TTL'] ?? null ); + $this->assertArrayNotHasKey( 'X-Kevinrob-GuzzleCache-TTL', $headers ); + } + public function testLegacyQueryUsesDefaultCacheKeyRequestHeaders(): void { $data_source = MockDataSource::create(); $this->assertInstanceOf( MockDataSource::class, $data_source ); diff --git a/tests/inc/Mocks/MockQuery.php b/tests/inc/Mocks/MockQuery.php index e8c2468b5..648be7192 100644 --- a/tests/inc/Mocks/MockQuery.php +++ b/tests/inc/Mocks/MockQuery.php @@ -13,6 +13,7 @@ class MockQuery extends HttpQuery { public static function create( array $config = [], ?ValidatorInterface $validator = null ): static|WP_Error { return self::from_array( [ + 'cache_ttl' => $config['cache_ttl'] ?? null, 'cache_key_request_headers' => $config['cache_key_request_headers'] ?? null, 'data_source' => $config['data_source'] ?? MockDataSource::create(), 'display_name' => 'Mock Query', From 78741db799a94c23a58bab278afe613d3bc0ae2e Mon Sep 17 00:00:00 2001 From: Max Schmeling Date: Wed, 19 Aug 2026 09:59:13 -0500 Subject: [PATCH 18/18] Remove unnecessary __invoke --- inc/HttpClient/RdbCacheMiddleware.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index ab9612d06..88f0f6359 100644 --- a/inc/HttpClient/RdbCacheMiddleware.php +++ b/inc/HttpClient/RdbCacheMiddleware.php @@ -5,14 +5,6 @@ use Psr\Http\Message\RequestInterface; class RdbCacheMiddleware extends \Kevinrob\GuzzleCache\CacheMiddleware { - public function __invoke( callable $handler ): callable { - $handler_without_cache_metadata = function ( RequestInterface $request, array $options ) use ( $handler ) { - return $handler( $request->withoutHeader( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ), $options ); - }; - - return parent::__invoke( $handler_without_cache_metadata ); - } - /** * @var array */