diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 1a4ff225..c5267a2e 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 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). diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index 8bed478a..c7b9fdbe 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -72,6 +72,8 @@ 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:** 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 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/query.md b/docs/extending/query.md index 9b215b09..be621fcc 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -139,6 +139,28 @@ 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, 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 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. + +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 +class CustomQuery implements HttpQueryInterface, CacheKeyRequestHeadersAwareInterface { + // ... + + 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. diff --git a/docs/for-ai.md b/docs/for-ai.md index e9d3a3d4..40a0814a 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,2288 +244,2636 @@ 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: -Run WP-CLI commands: +```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; + } -```sh -npm run wp-cli option get siteurl + return $data['places'][0]['place name'] . ', ' . $data['places'][0]['state abbreviation']; + }, + 'type' => 'string', + ], + ], +], ``` -### Destroy +- 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. -Destroy your local environment and irreversibly delete all content, configuration, and data: +The result of applying this output schema to the example JSON response is: -```sh -npm run dev:destroy +```php +[ + zip_code => '17057', + city_state => 'Middletown, PA', +] ``` -## Local playground +## Collection example -While not suitable for local developement, it can sometimes be useful to quickly spin up a local WordPress playground: +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): -```sh -npm run build # or `npm start` in a separate terminal -npm run playground +```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" + } +} ``` -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. -```` +An output schema can be defined as: -## File: example/assets/blueprint-content.wxr -```` - - - - - +```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', + ], + ], +], +``` - - remote-data-blocks - http://localhost:8888 - - Mon, 26 May 2025 21:28:18 +0000 - en-US - 1.2 - http://localhost:8888 - http://localhost:8888 +We can enhance the output schema with additional fields and options: - 1 +```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. - https://wordpress.org/?v=6.8.1 +Applying this output schema to the response JSON would result in the following output: - - <![CDATA[👋 Welcome!]]> - http://localhost:8888/?p=1 - Mon, 26 May 2025 21:15:05 +0000 - - http://localhost:8888/?p=1 - - -

Here is an example of a remote data block. It is connected to an example API that returns events for an upcoming conference. If the data from that API changes—even after the post is published—this block will reflect those changes.

- +```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 - -
- -

Community building workshop

- +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. - -

Emerald room

- +## Base and personal access token - -

Workshop

- -
- +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

- -

We can also create inline bindings that represent remote data in the same way: Lunch will be held in the President's dining hall.

- +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. - -

Add another Conference Event block below and explore how data is selected and configured. Or use the [/] button in the formatting toolbar to create an inline binding.

- +

create-pat

- -

Read more in our documentation!

-]]>
- - 1 - - - - - - - - - 0 - 0 - - - 0 - -
-
-
-```` +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. -## File: example/blocks/book-block/patterns/book-pattern.html -````html - -
- -
- -
- -
- +## 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 -## File: example/blocks/book-block/book-block.php -````php - -use function _n; -use function add_query_arg; +## Patterns and styling -/** - * Registers a remote data block representing book information from the Open Library API. - * This block allows users to search for books and display detailed information including - * title, author, publication date, and cover image. - * - * @see https://openlibrary.org/dev/docs/api/search - */ -function register_open_library_remote_data_block(): void { - $open_library_data_source = [ - 'display_name' => 'Open Library', - 'endpoint' => add_query_arg( [ 'fields' => 'key,title,author_name,first_publish_year,cover_i,edition_count' ], 'https://openlibrary.org/search.json' ), - 'request_headers' => [ - 'Content-Type' => 'application/json', - ], - ]; +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). - $get_book_query = [ - 'data_source' => $open_library_data_source, - 'display_name' => 'Get book details', - 'endpoint' => function ( array $input_variables ) use ( $open_library_data_source ): string { - $work_key = $input_variables['work_key'] ?? ''; - return add_query_arg( [ 'q' => $work_key ], $open_library_data_source['endpoint'] ); - }, - 'input_schema' => [ - 'work_key' => [ - 'name' => 'Work Key', - 'required' => true, - 'type' => 'id', - ], - ], - 'output_schema' => [ - 'is_collection' => false, - 'path' => '$.docs[0]', - 'type' => [ - 'work_key' => [ - 'name' => 'Work Key', - 'path' => '$.key', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.title', - 'type' => 'title', - ], - 'author_name' => [ - 'name' => 'Author', - 'path' => '$.author_name[0]', - 'type' => 'string', - ], - 'first_publish_year' => [ - 'name' => 'First Published', - 'path' => '$.first_publish_year', - 'default_value' => 'Unknown', - 'type' => 'string', - ], - 'cover_image_url' => [ - 'name' => 'Cover Image', - 'generate' => static function ( array $data ): string { - $cover_id = $data['cover_i'] ?? null; +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. - if ( $cover_id ) { - return "https://covers.openlibrary.org/b/id/{$cover_id}-M.jpg"; - } +## Code reference - return ''; - }, - 'type' => 'image_url', - ], - 'edition_count' => [ - 'name' => 'Editions', - 'path' => '$.edition_count', - 'default_value' => 0, - 'format' => static function ( $value ): string { - $count = is_numeric( $value ) ? (int) $value : 0; - /* translators: %d is the number of book editions */ - return sprintf( _n( '%d edition', '%d editions', $count ), $count ); - }, - 'type' => 'string', - ], - ], - ], - ]; +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_books_query = [ - 'data_source' => $open_library_data_source, - 'display_name' => 'Search books', - 'endpoint' => function ( array $input_variables ) use ( $open_library_data_source ): string { - $search_terms = $input_variables['search'] ?? ''; - $limit = $input_variables['limit'] ?? 10; - $page = $input_variables['page'] ?? 1; +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. +```` - $offset = ( $page - 1 ) * $limit; +## File: docs/tutorials/google-sheets.md +````markdown +# Create a Google Sheets remote data block - $query_params = [ - 'limit' => $limit, - 'offset' => $offset, - 'fields' => 'key,title,author_name,first_publish_year,cover_i,edition_count', - ]; +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. - if ( ! empty( $search_terms ) ) { - $query_params['q'] = $search_terms; - } +## Google Sheets API Access - return add_query_arg( $query_params, $open_library_data_source['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', - ], - ], - 'output_schema' => [ - 'is_collection' => true, - 'path' => '$.docs[*]', - 'type' => [ - 'work_key' => [ - 'name' => 'Work Key', - 'path' => '$.key', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.title', - 'type' => 'title', - ], - 'author_name' => [ - 'name' => 'Authors', - 'path' => '$.author_name[0]', - 'type' => 'string', - ], - 'first_publish_year' => [ - 'name' => 'First Published', - 'path' => '$.first_publish_year', - 'default_value' => 'Unknown', - 'type' => 'string', - ], - 'cover_image_url' => [ - 'name' => 'Cover Image', - 'generate' => static function ( array $data ): string { - $cover_id = $data['cover_i'] ?? null; +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: - if ( $cover_id ) { - return "https://covers.openlibrary.org/b/id/{$cover_id}-M.jpg"; - } +- [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. - return ''; - }, - 'type' => 'image_url', - ], - 'edition_count' => [ - 'name' => 'Editions', - 'path' => '$.edition_count', - 'default_value' => 0, - 'format' => static function ( $value ): string { - $count = is_numeric( $value ) ? (int) $value : 0; - /* translators: %d is the number of book editions */ - return sprintf( _n( '%d edition', '%d editions', $count ), $count ); - }, - 'type' => 'string', - ], - ], - ], - 'pagination_schema' => [ - 'total_items' => [ - 'name' => 'Total items', - 'path' => '$.numFound', - 'type' => 'integer', - ], - ], - ]; +## Setting up the Google Sheet - register_remote_data_block( [ - 'title' => 'Open Library Book', - 'icon' => 'book', - 'render_query' => [ - 'query' => $get_book_query, - ], - 'selection_queries' => [ - [ - 'query' => $search_books_query, - 'type' => 'search', - ], - ], - 'patterns' => [ - [ - 'title' => 'Book Details Layout', - 'html' => file_get_contents( __DIR__ . '/patterns/book-pattern.html' ), - 'role' => 'inner_blocks', // Bypass the pattern selection step. - ], - ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_open_library_remote_data_block' ); -```` +- 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. -## File: example/blocks/github-markdown-block/inc/patterns/file-render.html -````html - -
- -

- -
- -```` +## Create the data source -## File: example/blocks/github-markdown-block/inc/github-query-runner.php -````php - 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. -namespace RemoteDataBlocks\Example\GitHub; +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. -use RemoteDataBlocks\Config\Query\HttpQueryInterface; -use RemoteDataBlocks\Config\QueryRunner\QueryRunner; -use WP_Error; +6. Select your desired spreadsheet and sheets. +7. Save the data source and return the data source list. -defined( 'ABSPATH' ) || exit(); +## Insert the block -/** - * 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. - * - * Data fetching and caching is still delegated to the parent QueryRunner class. - */ -class GitHubQueryRunner extends QueryRunner { - private string $default_file_extension = '.md'; +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. - public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { - $input_variables['file_path'] = $this->ensure_file_extension( $input_variables['file_path'] ); +The loop block will return all the entries in the spreadsheet. - return parent::execute( $query, $input_variables ); - } +## Patterns and styling - /** - * @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'], - ]; - } +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). - 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; - } -} -```` +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. -## File: example/blocks/shopify-mock-store-block/shopify-mock-store-block.php -````php - [ - '__version' => 1, - 'access_token' => '', // No access token needed for the mock store. - 'display_name' => 'Shopify Mock Store', - 'store_name' => 'mock.shop', - ], - ] ); +## File: docs/tutorials/index.md +````markdown +# Tutorials - ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); -} -add_action( 'init', __NAMESPACE__ . '\\register_shopify_mock_store_blocks' ); +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: example/blocks/weather-block/patterns/weather-block-pattern.html -````html - -

- +## 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 +``` -## File: example/blocks/weather-block/weather-block.php -````php - 'Clear sky', - 1 => 'Mainly clear', - 2 => 'Partly cloudy', - 3 => 'Overcast', - 45 => 'Fog', - 48 => 'Depositing rime fog', - 51 => 'Light drizzle', - 53 => 'Moderate drizzle', - 55 => 'Dense drizzle', - 56 => 'Light freezing drizzle', - 57 => 'Dense freezing drizzle', - 61 => 'Slight rain', - 63 => 'Moderate rain', - 65 => 'Heavy rain', - 66 => 'Light freezing rain', - 67 => 'Heavy freezing rain', - 71 => 'Slight snow fall', - 73 => 'Moderate snow fall', - 75 => 'Heavy snow fall', - 77 => 'Snow grains', - 80 => 'Slight rain showers', - 81 => 'Moderate rain showers', - 82 => 'Violent rain showers', - 85 => 'Slight snow showers', - 86 => 'Heavy snow showers', - 95 => 'Thunderstorm', - 96 => 'Thunderstorm with slight hail', - 99 => 'Thunderstorm with heavy hail', - ]; +### Sharing configuration - return $weather_codes[ $code ] ?? 'Unknown'; -} +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. -/** - * Generate rain prediction based on precipitation probability - */ -function generate_rain_prediction( int $probability ): string { - if ( $probability >= 80 ) { - return 'It definitely looks like rain today!'; - } elseif ( $probability >= 20 ) { - return 'It might rain today.'; - } else { - return 'Rain is unlikely today.'; - } -} +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. -/** - * Registers a remote data block for fetching weather data from the OpenMeteo API. - * This block accepts a city name as input and returns current weather information - * including temperature, humidity, weather description, and rain prediction. - * - * @see https://open-meteo.com/en/docs - */ -function register_weather_remote_data_block(): void { - $openmeteo_data_source = [ - 'display_name' => 'OpenMeteo Weather API', - 'endpoint' => 'https://api.open-meteo.com/v1/', - 'request_headers' => [ - 'Content-Type' => 'application/json', +### 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: + +```sh +npm run wp-cli option get siteurl +``` + +### Destroy + +Destroy your local environment and irreversibly delete all content, configuration, and data: + +```sh +npm run dev:destroy +``` + +## Local playground + +While not suitable for local developement, it can sometimes be useful to quickly spin up a local WordPress playground: + +```sh +npm run build # or `npm start` in a separate terminal +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 +```` + + + + + + + + + + + + + + + + + + + + + + + remote-data-blocks + http://localhost:8888 + + Mon, 26 May 2025 21:28:18 +0000 + en-US + 1.2 + http://localhost:8888 + http://localhost:8888 + + 1 + + + https://wordpress.org/?v=6.8.1 + + + <![CDATA[👋 Welcome!]]> + http://localhost:8888/?p=1 + Mon, 26 May 2025 21:15:05 +0000 + + http://localhost:8888/?p=1 + + +

Here is an example of a remote data block. It is connected to an example API that returns events for an upcoming conference. If the data from that API changes—even after the post is published—this block will reflect those changes.

+ + + +
+ + + +
+ +

Community building workshop

+ + + +

Emerald room

+ + + +

Workshop

+ +
+ + + +
+ + + +

We can also create inline bindings that represent remote data in the same way: Lunch will be held in the President's dining hall.

+ + + +

Add another Conference Event block below and explore how data is selected and configured. Or use the [/] button in the formatting toolbar to create an inline binding.

+ + + +

Read more in our documentation!

+]]>
+ + 1 + + + + + + + + + 0 + 0 + + + 0 + +
+
+
+```` + +## 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_geo_data_from_city_query = [ - 'data_source' => $openmeteo_data_source, - 'display_name' => 'Get latitude and longitude from city name', - 'endpoint' => function ( array $input_variables ): string { - return add_query_arg( [ - 'name' => $input_variables['city'], - 'count' => 1, - 'language' => 'en', - 'format' => 'json', - ], 'https://geocoding-api.open-meteo.com/v1/search' ); + $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' => [ - 'city' => [ - 'name' => 'City Name', - 'type' => 'string', - 'required' => true, + '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' => false, - 'path' => '$.results[0]', + 'is_collection' => true, + 'path' => '$.data[*]', 'type' => [ - 'country' => [ - 'name' => 'Country', - 'path' => '$.country', - 'type' => 'string', + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + 'path' => '$.id', ], - 'lat' => [ - 'name' => 'Latitude', - 'path' => '$.latitude', - 'type' => 'number', + 'artist_title' => [ + 'name' => 'Artist Title', + 'type' => 'string', + 'path' => '$.artist_title', ], - 'long' => [ - 'name' => 'Longitude', - 'path' => '$.longitude', - 'type' => 'number', + 'title' => [ + 'name' => 'Title', + 'type' => 'title', + 'path' => '$.title', ], - 'name' => [ - 'name' => 'Name', - 'path' => '$.name', - 'type' => 'string', + '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', ], ], ], ]; - $get_weather_query = [ - 'data_source' => $openmeteo_data_source, - 'display_name' => 'Get weather by city name', - 'endpoint' => function ( array $input_variables ) use ( $openmeteo_data_source, $get_geo_data_from_city_query ): string { - // Get latitude and longitude from the city name by executing a dependent - // query. This approach can avoid the need for a custom query runner or - // other complicated configuration. - // - // Using `HttpQuery` allows us to benefit from the caching layer, which is - // important since this code runs on every request before the object cache - // is checked. - $geo_data_query = HttpQuery::from_array( $get_geo_data_from_city_query ); - $geo_data = $geo_data_query->execute( [ 'city' => $input_variables['city'] ] ); + $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'] ?? ''; - $latitude = $geo_data['results'][0]['result']['lat']['value'] ?? 'invalid'; - $longitude = $geo_data['results'][0]['result']['long']['value'] ?? 'invalid'; + // 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 ); + } - // Construct and return weather API URL return add_query_arg( [ - 'latitude' => $latitude, - 'longitude' => $longitude, - 'current' => 'temperature_2m,relative_humidity_2m,weather_code,precipitation_probability', - 'timezone' => 'auto', - 'temperature_unit' => 'celsius', - ], $openmeteo_data_source['endpoint'] . 'forecast' ); + 'limit' => $input_variables['limit'], + 'fields' => 'id,title,image_id,artist_title', + 'page' => $input_variables['page'], + ], $endpoint ); }, 'input_schema' => [ - 'city' => [ - 'name' => 'City Name', - 'type' => 'string', + '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 + +
+ +
+ +
+ +
+ + + +
+ +

+ + + +

+ + + +

+ + + +

+ +
+ +
+ +```` + +## File: example/blocks/book-block/book-block.php +````php + 'Open Library', + 'endpoint' => add_query_arg( [ 'fields' => 'key,title,author_name,first_publish_year,cover_i,edition_count' ], 'https://openlibrary.org/search.json' ), + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], + ]; + + $get_book_query = [ + 'data_source' => $open_library_data_source, + 'display_name' => 'Get book details', + 'endpoint' => function ( array $input_variables ) use ( $open_library_data_source ): string { + $work_key = $input_variables['work_key'] ?? ''; + return add_query_arg( [ 'q' => $work_key ], $open_library_data_source['endpoint'] ); + }, + 'input_schema' => [ + 'work_key' => [ + 'name' => 'Work Key', 'required' => true, + 'type' => 'id', ], ], 'output_schema' => [ - 'is_collection' => false, // This query returns a single weather record + 'is_collection' => false, + 'path' => '$.docs[0]', 'type' => [ - 'location_name' => [ - 'name' => 'Location', - 'type' => 'string', - 'generate' => function ( array $_data, array $response_data ): string { - return $response_data['input_variables']['city'] ?? 'Unknown'; - }, - ], - 'temperature_celsius' => [ - 'name' => 'Temperature (°C)', - 'type' => 'number', - 'path' => '$.current.temperature_2m', - ], - 'temperature_fahrenheit' => [ - 'name' => 'Temperature (°F)', - 'type' => 'number', - 'generate' => function ( array $data ): float { - $temp_c = $data['current']['temperature_2m'] ?? 0; - return round( ( $temp_c * 9 / 5 ) + 32, 1 ); - }, - ], - 'weather_description' => [ - 'name' => 'Weather Description', - 'type' => 'string', - 'generate' => function ( array $data ): string { - $weather_code = $data['current']['weather_code'] ?? 0; - return get_weather_description( (int) $weather_code ); - }, + 'work_key' => [ + 'name' => 'Work Key', + 'path' => '$.key', + 'type' => 'id', ], - 'humidity' => [ - 'name' => 'Humidity (%)', - 'type' => 'integer', - 'path' => '$.current.relative_humidity_2m', + 'title' => [ + 'name' => 'Title', + 'path' => '$.title', + 'type' => 'title', ], - 'precipitation_probability' => [ - 'name' => 'Precipitation Probability (%)', - 'type' => 'integer', - 'path' => '$.current.precipitation_probability', + 'author_name' => [ + 'name' => 'Author', + 'path' => '$.author_name[0]', + 'type' => 'string', ], - 'rain_prediction' => [ - 'name' => 'Rain Prediction', + 'first_publish_year' => [ + 'name' => 'First Published', + 'path' => '$.first_publish_year', + 'default_value' => 'Unknown', 'type' => 'string', - 'generate' => function ( array $data ): string { - $probability = $data['current']['precipitation_probability'] ?? 0; - return generate_rain_prediction( (int) $probability ); + ], + 'cover_image_url' => [ + 'name' => 'Cover Image', + 'generate' => static function ( array $data ): string { + $cover_id = $data['cover_i'] ?? null; + + if ( $cover_id ) { + return "https://covers.openlibrary.org/b/id/{$cover_id}-M.jpg"; + } + + return ''; + }, + 'type' => 'image_url', + ], + 'edition_count' => [ + 'name' => 'Editions', + 'path' => '$.edition_count', + 'default_value' => 0, + 'format' => static function ( $value ): string { + $count = is_numeric( $value ) ? (int) $value : 0; + /* translators: %d is the number of book editions */ + return sprintf( _n( '%d edition', '%d editions', $count ), $count ); }, + 'type' => 'string', ], ], ], ]; - register_remote_data_block( [ - 'title' => 'Weather', - 'icon' => 'cloud', - 'render_query' => [ - 'query' => $get_weather_query, - ], - // Supply a pattern for the block that will be used to display the weather - // data. This takes the place of the default pattern provided by the plugin. - 'patterns' => [ - [ - 'title' => 'Weather for city', - 'html' => file_get_contents( __DIR__ . '/patterns/weather-block-pattern.html' ), - 'role' => 'inner_blocks', // Bypass the pattern selection step. - ], - ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_weather_remote_data_block' ); -```` + $search_books_query = [ + 'data_source' => $open_library_data_source, + 'display_name' => 'Search books', + 'endpoint' => function ( array $input_variables ) use ( $open_library_data_source ): string { + $search_terms = $input_variables['search'] ?? ''; + $limit = $input_variables['limit'] ?? 10; + $page = $input_variables['page'] ?? 1; -## File: example/blocks/zip-code-block/zip-code-block.php -````php - $limit, + 'offset' => $offset, + 'fields' => 'key,title,author_name,first_publish_year,cover_i,edition_count', + ]; -/** - * Registers a remote data block for fetching zip code information from the - * Zippopotam.us API. - * - * @see https://www.zippopotam.us/ - */ -function register_zip_code_remote_data_block(): void { - $zip_code_data_source = [ - 'display_name' => 'Zip Code', - 'endpoint' => 'https://api.zippopotam.us/us/', - ]; + if ( ! empty( $search_terms ) ) { + $query_params['q'] = $search_terms; + } - $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']; + return add_query_arg( $query_params, $open_library_data_source['endpoint'] ); }, 'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', + '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', ], ], 'output_schema' => [ - 'is_collection' => false, // This query returns a single record. + 'is_collection' => true, + 'path' => '$.docs[*]', 'type' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'path' => '$["post code"]', // JSON property with space requires brackets and quotes. + 'work_key' => [ + 'name' => 'Work Key', + 'path' => '$.key', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'path' => '$.title', + 'type' => 'title', + ], + 'author_name' => [ + 'name' => 'Authors', + 'path' => '$.author_name[0]', 'type' => 'string', ], - 'city' => [ - 'name' => 'City', - 'path' => '$.places[0]["place name"]', // JSON property with space requires brackets and quotes. + 'first_publish_year' => [ + 'name' => 'First Published', + 'path' => '$.first_publish_year', + 'default_value' => 'Unknown', 'type' => 'string', ], - 'state' => [ - 'name' => 'State', - 'path' => '$.places[0].state', + 'cover_image_url' => [ + 'name' => 'Cover Image', + 'generate' => static function ( array $data ): string { + $cover_id = $data['cover_i'] ?? null; + + if ( $cover_id ) { + return "https://covers.openlibrary.org/b/id/{$cover_id}-M.jpg"; + } + + return ''; + }, + 'type' => 'image_url', + ], + 'edition_count' => [ + 'name' => 'Editions', + 'path' => '$.edition_count', + 'default_value' => 0, + 'format' => static function ( $value ): string { + $count = is_numeric( $value ) ? (int) $value : 0; + /* translators: %d is the number of book editions */ + return sprintf( _n( '%d edition', '%d editions', $count ), $count ); + }, 'type' => 'string', ], ], ], + 'pagination_schema' => [ + 'total_items' => [ + 'name' => 'Total items', + 'path' => '$.numFound', + 'type' => 'integer', + ], + ], ]; register_remote_data_block( [ - 'title' => 'Zip Code', + 'title' => 'Open Library Book', + 'icon' => 'book', 'render_query' => [ - 'query' => $zip_code_query, + 'query' => $get_book_query, ], - ] ); -} -add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); -```` - -## File: example/templates/airtable-block/airtable-block.php -````php - [ - '__version' => 1, - 'access_token' => '{{ Access Token }}', // Airtable access token ("pat...") - 'base' => [ - 'id' => '{{ Base ID }}', // Airtable base ID ("app...") - 'name' => 'Conference Events', + 'selection_queries' => [ + [ + 'query' => $search_books_query, + 'type' => 'search', ], - 'display_name' => 'Conference Events', - 'tables' => [ - [ - 'id' => '{{ Table ID }}', // Airtable table ID ("tbl...") - 'name' => 'Conference Events', - // These mappings correspond to the columns of the table. - 'output_query_mappings' => [ - [ - 'key' => 'record_id', - 'name' => 'ID', - 'path' => '$.id', - 'type' => 'id', - ], - [ - 'key' => 'title', - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - [ - 'key' => 'type', - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - [ - 'key' => 'location', - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - [ - 'key' => 'notes', - 'name' => 'Notes', - 'path' => '$.fields.Notes', - 'type' => 'string', - ], - ], - ], + ], + 'patterns' => [ + [ + 'title' => 'Book Details Layout', + 'html' => file_get_contents( __DIR__ . '/patterns/book-pattern.html' ), + 'role' => 'inner_blocks', // Bypass the pattern selection step. ], ], ] ); - - AirtableIntegration::register_blocks_for_airtable_data_source( $airtable_data_source ); } -add_action( 'init', 'register_airtable_remote_data_block' ); +add_action( 'init', __NAMESPACE__ . '\\register_open_library_remote_data_block' ); ```` -## File: example/templates/airtable-map-block/src/leaflet-map/block.json -````json -{ - "$schema": "https://schemas.wp.org/trunk/block.json", - "apiVersion": 3, - "name": "example/leaflet-map", - "version": "1.0.0", - "title": "Leaflet Map", - "category": "widgets", - "icon": "location-alt", - "example": {}, - "supports": { - "html": false - }, - "textdomain": "remote-data-blocks-examples", - "editorScript": [ "file:./index.js", "leaflet-script" ], - "editorStyle": [ "leaflet-style" ], - "render": "file:./render.php", - "viewScript": [ "file:./view.js", "leaflet-script" ], - "viewStyle": [ "leaflet-style" ] -} +## File: example/blocks/github-markdown-block/inc/patterns/file-render.html +````html + +
+ +

+ +
+ ```` -## File: example/templates/airtable-map-block/src/leaflet-map/edit.js -````javascript -import { useEffect } from '@wordpress/element'; -import ServerSideRender from '@wordpress/server-side-render'; - -import metadata from './block.json'; -import { initMaps } from './view'; - -/* global document */ - -/** - * The map elements are rendered differently in the block editor vs the WordPress - * frontend. This hook handles the differences. - */ -function useMapInit() { - useEffect( () => { - // In the block editor, the document can be iframed. - const parentDocument = - document.querySelector( 'iframe[name="editor-canvas"]' )?.contentDocument ?? document; - - // Use an interval to make sure we get elements that might arrive "late" due - // to client-side rendering or because they are rendered in the block editor. - // - // Using `ServerSideRender` allows us to rely on the markup generated by - // `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]' - ); - - if ( mapElement ) { - initMaps( [ mapElement ] ); - clearInterval( timer ); - } - }, 100 ); +## File: example/blocks/github-markdown-block/inc/github-query-runner.php +````php + clearInterval( timer ); - }, [] ); -} +namespace RemoteDataBlocks\Example\GitHub; -export function Edit() { - useMapInit(); +use RemoteDataBlocks\Config\Query\HttpQueryInterface; +use RemoteDataBlocks\Config\QueryRunner\QueryRunner; +use WP_Error; - // ServerSideRender allows us to reuse the markup generated by `render.php` - // instead of duplicating the rendering logic in JavaScript. - return ; -} -```` +defined( 'ABSPATH' ) || exit(); -## 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. + * 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. * - * @see https://developer.wordpress.org/block-editor/reference-guides/block-api/block-registration/ - */ -import { registerBlockType } from '@wordpress/blocks'; - -/** - * Internal dependencies + * Data fetching and caching is still delegated to the parent QueryRunner class. */ -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', - ], - ], -]; +class GitHubQueryRunner extends QueryRunner { + private string $default_file_extension = '.md'; -$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 ], - ], -] ); + public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { + $input_variables['file_path'] = $this->ensure_file_extension( $input_variables['file_path'] ); -$get_locations_query = AirtableIntegration::get_list_query( $map_data_source, $table ); -$response = $get_locations_query->execute( [] ); -$coordinates = []; + return parent::execute( $query, $input_variables ); + } -if ( ! is_wp_error( $response ) ) { - $coordinates = array_map( function ( $value ) { - $result = $value['result']; + /** + * @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 [ - 'name' => $result['name']['value'], - 'x' => $result['x']['value'], - 'y' => $result['y']['value'], + 'content' => $raw_response_data, + 'path' => $input_variables['file_path'], ]; - }, $response['results'] ); -} + } -?> -
- data-map-coordinates="" - style="height: 400px;" -> -
+ 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/templates/airtable-map-block/src/leaflet-map/view.js -````javascript -import domReady from '@wordpress/dom-ready'; - -/* global document, leaflet */ - -export function initMaps( mapElements ) { - mapElements.forEach( element => { - const data = element?.dataset.mapCoordinates ?? ''; - - let coordinates = []; - try { - coordinates = JSON.parse( data ) ?? []; - } catch ( error ) {} - - delete element.dataset.mapCoordinates; - - const map = leaflet.map( element ).setView( [ coordinates[ 0 ].x, coordinates[ 0 ].y ], 25 ); - const layerGroup = leaflet.layerGroup().addTo( map ); - - leaflet - .tileLayer( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 4 } ) - .addTo( map ); +## File: example/blocks/github-markdown-block/inc/markdown-links.php +````php + location.x && location.y ) - .forEach( location => { - leaflet.marker( [ location.x, location.y ], { title: location.name } ).addTo( layerGroup ); - } ); +namespace RemoteDataBlocks\Example\GitHub; - map.flyTo( [ coordinates[ 0 ].x, coordinates[ 0 ].y ] ); - } ); -} +use DOMDocument; +use DOMElement; +use DOMXPath; -// When the document is ready, find all maps and initialize them with Leaflet. -domReady( () => { - initMaps( document.querySelectorAll( '.wp-block-example-leaflet-map[data-map-coordinates]' ) ); -} ); -```` +/** + * 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(); -## File: example/templates/airtable-map-block/.gitignore -```` -/build/ -/node_modules/ -/package-lock.json -```` + // Convert HTML to UTF-8 using htmlspecialchars instead of mb_convert_encoding + $html = '' . $html; -## File: example/templates/airtable-map-block/airtable-map-block.php -````php -loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); -function register_leaflet_map_remote_data_block(): void { - // Register the Leaflet script and stylesheet. The handles are referenced in - // `block.json` for use in the block editor and the WordPress frontend. - wp_register_style( 'leaflet-style', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], '1.9.4' ); - wp_register_script( 'leaflet-script', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js', [], '1.9.4', true ); + // Create an XPath to query href attributes + $xpath = new DOMXPath( $dom ); - /** - * Registers the block(s) metadata from the `blocks-manifest.php` and registers the block type(s) - * based on the registered block metadata. - * Added in WordPress 6.8 to simplify the block metadata registration process added in WordPress 6.7. - * - * @see https://make.wordpress.org/core/2025/03/13/more-efficient-block-type-registration-in-6-8/ - */ - wp_register_block_types_from_metadata_collection( __DIR__ . '/build', __DIR__ . '/build/blocks-manifest.php' ); -} -add_action( 'init', 'register_leaflet_map_remote_data_block' ); -```` + // Query all elements with href attributes + $nodes = $xpath->query( '//*[@href]' ); + foreach ( $nodes as $node ) { + if ( ! $node instanceof DOMElement ) { + continue; + } + $href = $node->getAttribute( 'href' ); -## File: example/templates/airtable-map-block/package.json -````json -{ - "name": "map-block", - "version": "0.1.0", - "description": "Example block scaffolded with Create Block tool.", - "author": "The WordPress Contributors", - "license": "GPL-2.0-or-later", - "main": "build/index.js", - "scripts": { - "build": "wp-scripts build --blocks-manifest", - "format": "wp-scripts format", - "lint:css": "wp-scripts lint-style", - "lint:js": "wp-scripts lint-js", - "packages-update": "wp-scripts packages-update", - "plugin-zip": "wp-scripts plugin-zip", - "start": "wp-scripts start --blocks-manifest" - }, - "devDependencies": { - "@wordpress/blocks": "14.13.0", - "@wordpress/dom-ready": "4.24.0", - "@wordpress/scripts": "^30.17.0" + // 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 ); + + // Set the new href + $node->setAttribute( 'href', $new_href ); + } + } + + // 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() ); } -```` -## File: example/templates/airtable-map-block/README.md -````markdown -# Example: "Leaflet Map" block -This example illustrates the flexibility of the Remote Data Blocks plugin. Instead of registering a block via `register_remote_data_block`, this example builds a custom dynamic block that uses the [Leaflet library](https://leafletjs.com) to display a map with marked locations. +/** + * Adjusts the markdown file path by resolving relative paths to absolute paths. + * Preserves fragment identifiers (anchors) in the URL. + * + * @param string $path The original path. + * @param string $current_file_path The current file's path. + * @return string The adjusted path. + */ +function adjust_markdown_file_path( string $path, string $current_file_path = '' ): string { + global $post; + $page_slug = $post->post_name; -The map locations are loaded from an Airtable base that contains longitude and latitude coordinates. Instead of using block bindings, this example creates a data source and a query and executes it manually in `render.php`. + // Parse the URL to separate the path and fragment + $parts = wp_parse_url( $path ); -The result is a registered "Leaflet Map" block that renders remote data in the block editor and on the WordPress frontend. + // Extract the path and fragment + $original_path = isset( $parts['path'] ) ? $parts['path'] : ''; + $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; -

A Leaflet Map block in the block editor

+ // Get the directory of the current file + $current_dir = dirname( $current_file_path ); -

A Leaflet Map block in the WordPress frontend

+ // 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 = []; -## Build step + foreach ( $parts as $part ) { + if ( '.' === $part || '' === $part ) { + continue; + } + if ( '..' === $part ) { + array_pop( $absolute_parts ); + } else { + $absolute_parts[] = $part; + } + } -Because the custom block uses JSX, it requires a build step: `npm run build`. + $absolute_path = implode( '/', $absolute_parts ); + } -If you want to adapt this example code in your own codebase, we recommend using [the `@wordpress/create-block` utility](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) to scaffold your custom block. + // 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; +} ```` -## File: example/templates/google-sheets-block/google-sheets-block.php +## File: example/blocks/shopify-mock-store-block/shopify-mock-store-block.php ````php - [ '__version' => 1, - 'credentials' => $credentials, - 'display_name' => 'Westeros Houses', - 'spreadsheet' => [ - 'id' => $spreadsheet_id, - ], - 'sheets' => [ - [ - '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', - ], - ], - ], - ], + 'access_token' => '', // No access token needed for the mock store. + 'display_name' => 'Shopify Mock Store', + 'store_name' => 'mock.shop', ], ] ); - GoogleSheetsIntegration::register_blocks_for_google_sheets_data_source( $westeros_houses_data_source ); + ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); } -add_action( 'init', 'register_google_sheets_remote_data_block' ); +add_action( 'init', __NAMESPACE__ . '\\register_shopify_mock_store_blocks' ); +```` + +## File: example/blocks/weather-block/patterns/weather-block-pattern.html +````html + +

+ + + +

+ + + +

+ + + +

+ + + +

+ ```` -## File: example/templates/rest-api-block-from-ui-data-source/rest-api-block-from-ui-data-source.php +## File: example/blocks/weather-block/weather-block.php ````php - 'Clear sky', + 1 => 'Mainly clear', + 2 => 'Partly cloudy', + 3 => 'Overcast', + 45 => 'Fog', + 48 => 'Depositing rime fog', + 51 => 'Light drizzle', + 53 => 'Moderate drizzle', + 55 => 'Dense drizzle', + 56 => 'Light freezing drizzle', + 57 => 'Dense freezing drizzle', + 61 => 'Slight rain', + 63 => 'Moderate rain', + 65 => 'Heavy rain', + 66 => 'Light freezing rain', + 67 => 'Heavy freezing rain', + 71 => 'Slight snow fall', + 73 => 'Moderate snow fall', + 75 => 'Heavy snow fall', + 77 => 'Snow grains', + 80 => 'Slight rain showers', + 81 => 'Moderate rain showers', + 82 => 'Violent rain showers', + 85 => 'Slight snow showers', + 86 => 'Heavy snow showers', + 95 => 'Thunderstorm', + 96 => 'Thunderstorm with slight hail', + 99 => 'Thunderstorm with heavy hail', + ]; - // Get item query: Fetch one record by ID. - $get_item_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']; - $item_id = $input_variables['id'] ?? ''; + return $weather_codes[ $code ] ?? 'Unknown'; +} - return $endpoint . '/items/' . $item_id; +/** + * Generate rain prediction based on precipitation probability + */ +function generate_rain_prediction( int $probability ): string { + if ( $probability >= 80 ) { + return 'It definitely looks like rain today!'; + } elseif ( $probability >= 20 ) { + return 'It might rain today.'; + } else { + return 'Rain is unlikely today.'; + } +} + +/** + * Registers a remote data block for fetching weather data from the OpenMeteo API. + * This block accepts a city name as input and returns current weather information + * including temperature, humidity, weather description, and rain prediction. + * + * @see https://open-meteo.com/en/docs + */ +function register_weather_remote_data_block(): void { + $openmeteo_data_source = [ + 'display_name' => 'OpenMeteo Weather API', + 'endpoint' => 'https://api.open-meteo.com/v1/', + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], + ]; + + $get_geo_data_from_city_query = [ + 'data_source' => $openmeteo_data_source, + 'display_name' => 'Get latitude and longitude from city name', + 'endpoint' => function ( array $input_variables ): string { + return add_query_arg( [ + 'name' => $input_variables['city'], + 'count' => 1, + 'language' => 'en', + 'format' => 'json', + ], 'https://geocoding-api.open-meteo.com/v1/search' ); }, 'input_schema' => [ - 'id' => [ - 'name' => 'Item ID', - 'type' => 'id', + 'city' => [ + 'name' => 'City Name', + 'type' => 'string', + 'required' => true, ], ], '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', + 'is_collection' => false, + 'path' => '$.results[0]', 'type' => [ - 'id' => [ - 'name' => 'ID', - 'type' => 'id', - 'path' => '$.id', + 'country' => [ + 'name' => 'Country', + 'path' => '$.country', + 'type' => 'string', ], - 'title' => [ - 'name' => 'Title', - 'type' => 'title', - 'path' => '$.title', + 'lat' => [ + 'name' => 'Latitude', + 'path' => '$.latitude', + 'type' => 'number', ], - 'description' => [ - 'name' => 'Description', - 'type' => 'string', - 'path' => '$.description', + 'long' => [ + 'name' => 'Longitude', + 'path' => '$.longitude', + 'type' => 'number', ], - 'image_url' => [ - 'name' => 'Image URL', - 'type' => 'image_url', - 'path' => '$.image_url', + 'name' => [ + 'name' => 'Name', + 'path' => '$.name', + 'type' => 'string', ], - // 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']; - } + $get_weather_query = [ + 'data_source' => $openmeteo_data_source, + 'display_name' => 'Get weather by city name', + 'endpoint' => function ( array $input_variables ) use ( $openmeteo_data_source, $get_geo_data_from_city_query ): string { + // Get latitude and longitude from the city name by executing a dependent + // query. This approach can avoid the need for a custom query runner or + // other complicated configuration. + // + // Using `HttpQuery` allows us to benefit from the caching layer, which is + // important since this code runs on every request before the object cache + // is checked. + $geo_data_query = HttpQuery::from_array( $get_geo_data_from_city_query ); + $geo_data = $geo_data_query->execute( [ 'city' => $input_variables['city'] ] ); - // 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']; - } + $latitude = $geo_data['results'][0]['result']['lat']['value'] ?? 'invalid'; + $longitude = $geo_data['results'][0]['result']['long']['value'] ?? 'invalid'; - return add_query_arg( $query_params, $endpoint ); + // Construct and return weather API URL + return add_query_arg( [ + 'latitude' => $latitude, + 'longitude' => $longitude, + 'current' => 'temperature_2m,relative_humidity_2m,weather_code,precipitation_probability', + 'timezone' => 'auto', + 'temperature_unit' => 'celsius', + ], $openmeteo_data_source['endpoint'] . 'forecast' ); }, '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', + 'city' => [ + 'name' => 'City Name', + 'type' => 'string', + 'required' => true, ], ], - // 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', + 'output_schema' => [ + 'is_collection' => false, // This query returns a single weather record + 'type' => [ + 'location_name' => [ + 'name' => 'Location', + 'type' => 'string', + 'generate' => function ( array $_data, array $response_data ): string { + return $response_data['input_variables']['city'] ?? 'Unknown'; + }, + ], + 'temperature_celsius' => [ + 'name' => 'Temperature (°C)', + 'type' => 'number', + 'path' => '$.current.temperature_2m', + ], + 'temperature_fahrenheit' => [ + 'name' => 'Temperature (°F)', + 'type' => 'number', + 'generate' => function ( array $data ): float { + $temp_c = $data['current']['temperature_2m'] ?? 0; + return round( ( $temp_c * 9 / 5 ) + 32, 1 ); + }, + ], + 'weather_description' => [ + 'name' => 'Weather Description', + 'type' => 'string', + 'generate' => function ( array $data ): string { + $weather_code = $data['current']['weather_code'] ?? 0; + return get_weather_description( (int) $weather_code ); + }, + ], + 'humidity' => [ + 'name' => 'Humidity (%)', + 'type' => 'integer', + 'path' => '$.current.relative_humidity_2m', + ], + 'precipitation_probability' => [ + 'name' => 'Precipitation Probability (%)', + 'type' => 'integer', + 'path' => '$.current.precipitation_probability', + ], + 'rain_prediction' => [ + 'name' => 'Rain Prediction', + 'type' => 'string', + 'generate' => function ( array $data ): string { + $probability = $data['current']['precipitation_probability'] ?? 0; + return generate_rain_prediction( (int) $probability ); + }, + ], ], ], ]; - // Register the remote data block. register_remote_data_block( [ - 'title' => '{{ Block name }}', + 'title' => 'Weather', + 'icon' => 'cloud', 'render_query' => [ - 'query' => $get_item_query, + 'query' => $get_weather_query, ], - 'selection_queries' => [ + // Supply a pattern for the block that will be used to display the weather + // data. This takes the place of the default pattern provided by the plugin. + 'patterns' => [ [ - 'query' => $list_items_query, - 'type' => 'search', + 'title' => 'Weather for city', + 'html' => file_get_contents( __DIR__ . '/patterns/weather-block-pattern.html' ), + 'role' => 'inner_blocks', // Bypass the pattern selection step. ], ], - // 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_from_uuid' ); +add_action( 'init', __NAMESPACE__ . '\\register_weather_remote_data_block' ); ```` -## File: example/templates/shopify-product-block/shopify-product-block.php +## File: example/blocks/zip-code-block/zip-code-block.php ````php - [ - '__version' => 1, - 'access_token' => '{{ Access Token }}', - 'display_name' => '{{ Shopify Store Display Name }}', - 'store_name' => '{{ store-name.myshopify.com }}', +function register_zip_code_remote_data_block(): void { + $zip_code_data_source = [ + 'display_name' => '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', + ], + ], + ], + ]; - ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); + register_remote_data_block( [ + 'title' => 'Zip Code', + 'render_query' => [ + 'query' => $zip_code_query, + ], + ] ); } -add_action( 'init', 'register_shopify_remote_data_block' ); +add_action( 'init', __NAMESPACE__ . '\\register_zip_code_remote_data_block' ); ```` -## File: example/templates/theme/functions.php +## File: example/templates/airtable-block/airtable-block.php ````php -get( 'Version' ) - ); -} -add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); -add_action( 'enqueue_block_assets', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); -```` - -## File: example/templates/theme/README.md -````markdown -# Remote Data Blocks Example Theme + [ + '__version' => 1, + 'access_token' => '{{ Access Token }}', // Airtable access token ("pat...") + 'base' => [ + 'id' => '{{ Base ID }}', // Airtable base ID ("app...") + 'name' => 'Conference Events', + ], + 'display_name' => 'Conference Events', + 'tables' => [ + [ + 'id' => '{{ Table ID }}', // Airtable table ID ("tbl...") + 'name' => 'Conference Events', + // These mappings correspond to the columns of the table. + 'output_query_mappings' => [ + [ + 'key' => 'record_id', + 'name' => 'ID', + 'path' => '$.id', + 'type' => 'id', + ], + [ + 'key' => 'title', + 'name' => 'Title', + 'path' => '$.fields.Activity', + 'type' => 'string', + ], + [ + 'key' => 'type', + 'name' => 'Type', + 'path' => '$.fields.Type', + 'type' => 'string', + ], + [ + 'key' => 'location', + 'name' => 'Location', + 'path' => '$.fields.Location', + 'type' => 'string', + ], + [ + 'key' => 'notes', + 'name' => 'Notes', + 'path' => '$.fields.Notes', + 'type' => 'string', + ], + ], + ], + ], + ], + ] ); -.wp-block-remote-data-blocks-shopify-product p.rdb-block-data-price { - font-weight: 700; + AirtableIntegration::register_blocks_for_airtable_data_source( $airtable_data_source ); } +add_action( 'init', 'register_airtable_remote_data_block' ); ```` - -## File: example/templates/theme/style.css -````css -/*! - * Theme Name: Remote Data Blocks Example Theme - * Description: Example theme that provides styling for remote data blocks - * Version: 1.0.0 - * Template: twentytwentyfour - * Tags: remote-data-blocks - * Text Domain: remote-data-blocks - * Tested up to: 6.6 - * Requires at least: 6.6 - * Requires PHP: 8.1 - * License: GNU General Public License v2.0 - * License URI: https://www.gnu.org/licenses/gpl-2.0.html - */ - -/* This file is not enqueued and exists only to provide the theme manifest. */ -```` - -## File: example/templates/theme/theme.json -````json -{ - "$schema": "https://schemas.wp.org/trunk/theme.json", - "version": 3, - "settings": { - "blocks": { - "remote-data-blocks/conference-event": { - "custom": { - "remote-data-blocks": {} - } - } - } + +## File: example/templates/airtable-map-block/src/leaflet-map/block.json +````json +{ + "$schema": "https://schemas.wp.org/trunk/block.json", + "apiVersion": 3, + "name": "example/leaflet-map", + "version": "1.0.0", + "title": "Leaflet Map", + "category": "widgets", + "icon": "location-alt", + "example": {}, + "supports": { + "html": false }, - "styles": { - "elements": {}, - "blocks": { - "remote-data-blocks/conference-event": { - "color": { - "background": "#e9c9f9", - "text": "#290939" - }, - "css": "& p { margin: 0.25rem 0; }", - "shadow": "rgb(38, 57, 77) 0px 20px 30px -10px", - "spacing": { - "margin": { - "bottom": "2rem", - "top": "2rem" - }, - "padding": { - "bottom": "1.5rem", - "left": "1.5rem", - "right": "1.5rem", - "top": "1.5rem" - } - }, - "typography": { - "fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif", - "fontSize": "1.25rem" - }, - "elements": { - "heading": { - "border": { - "bottom": { - "color": "#593969", - "style": "solid", - "width": "3px" - } - }, - "color": { - "text": "#290939" - }, - "spacing": { - "margin": { - "top": "0.5rem" - }, - "padding": { - "bottom": "0.5rem" - } - }, - "typography": { - "fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif", - "fontSize": "2rem", - "fontWeight": "800" - } - } - } - }, - "remote-data-blocks/shopify-product": { - "css": "& .wp-block-columns { flex-direction: row-reverse }", - "spacing": { - "margin": { - "bottom": "2rem", - "top": "2rem" - }, - "padding": { - "bottom": "1.5rem", - "left": "1.5rem", - "right": "1.5rem", - "top": "1.5rem" - } - }, - "typography": { - "fontSize": "1rem" - }, - "elements": { - "heading": { - "color": { - "text": "#290939" - }, - "spacing": { - "margin": { - "top": "0.5rem" - }, - "padding": { - "bottom": "0.5rem" - } - }, - "typography": { - "fontFamily": "Helvetica Neue, Helvetica, Arial, sans-serif", - "fontSize": "1.25rem", - "fontWeight": "900" - } - } - } - } - } - } + "textdomain": "remote-data-blocks-examples", + "editorScript": [ "file:./index.js", "leaflet-script" ], + "editorStyle": [ "leaflet-style" ], + "render": "file:./render.php", + "viewScript": [ "file:./view.js", "leaflet-script" ], + "viewStyle": [ "leaflet-style" ] } ```` -## File: docs/concepts/inline-bindings.md -````markdown -# Inline bindings +## File: example/templates/airtable-map-block/src/leaflet-map/edit.js +````javascript +import { useEffect } from '@wordpress/element'; +import ServerSideRender from '@wordpress/server-side-render'; -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. +import metadata from './block.json'; +import { initMaps } from './view'; -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: +/* global document */ -Inline binding button +/** + * The map elements are rendered differently in the block editor vs the WordPress + * frontend. This hook handles the differences. + */ +function useMapInit() { + useEffect( () => { + // In the block editor, the document can be iframed. + const parentDocument = + document.querySelector( 'iframe[name="editor-canvas"]' )?.contentDocument ?? document; -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. + // Use an interval to make sure we get elements that might arrive "late" due + // to client-side rendering or because they are rendered in the block editor. + // + // Using `ServerSideRender` allows us to rely on the markup generated by + // `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]' + ); -A bulleted list using several inline bindings to describe three conference events + if ( mapElement ) { + initMaps( [ mapElement ] ); + clearInterval( timer ); + } + }, 100 ); -Inline bindings compile to HTML, so they are portable, safe, and have a built-in fallback. -```` + return () => clearInterval( timer ); + }, [] ); +} -## File: docs/extending/hooks.md -````markdown -# Hooks +export function Edit() { + useMapInit(); -Hooks are a way for one piece of code to interact/modify another piece of code at specific, pre-defined spots. + // ServerSideRender allows us to reuse the markup generated by `render.php` + // instead of duplicating the rendering logic in JavaScript. + return ; +} +```` -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. +## 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'; -[Read more about Hooks](https://developer.wordpress.org/plugins/hooks/) +/** + * Internal dependencies + */ +import metadata from './block.json'; +import { Edit } from './edit'; -## Actions +registerBlockType( metadata.name, { + ...metadata, + edit: Edit, + save: () => null, // A pure dynamic block only serializes its attributes. +} ); +```` -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. +## File: example/templates/airtable-map-block/src/leaflet-map/view.js +````javascript +import domReady from '@wordpress/dom-ready'; -### remote_data_blocks_loaded +/* global document, leaflet */ -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. +export function initMaps( mapElements ) { + mapElements.forEach( element => { + const data = element?.dataset.mapCoordinates ?? ''; -```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 -} + let coordinates = []; + try { + coordinates = JSON.parse( data ) ?? []; + } catch ( error ) {} -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' ); -} -``` + delete element.dataset.mapCoordinates; -### remote_data_blocks_log + const map = leaflet.map( element ).setView( [ coordinates[ 0 ].x, coordinates[ 0 ].y ], 25 ); + const layerGroup = leaflet.layerGroup().addTo( map ); -If you want to send debugging information to another source besides [Query Monitor](../troubleshooting.md#query-monitor), use the `remote_data_blocks_log` action. + leaflet + .tileLayer( 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 4 } ) + .addTo( map ); -```php -function custom_log( string $namespace, string $level, string $message, array $context ): void { - // Send the log to a custom destination. + coordinates + .filter( location => location.x && location.y ) + .forEach( location => { + leaflet.marker( [ location.x, location.y ], { title: location.name } ).addTo( layerGroup ); + } ); + + map.flyTo( [ coordinates[ 0 ].x, coordinates[ 0 ].y ] ); + } ); } -add_action( 'remote_data_blocks_log', 'custom_log', 10, 4 ); -``` -## Filters +// When the document is ready, find all maps and initialize them with Leaflet. +domReady( () => { + initMaps( document.querySelectorAll( '.wp-block-example-leaflet-map[data-map-coordinates]' ) ); +} ); +```` + +## File: example/templates/airtable-map-block/.gitignore +```` +/build/ +/node_modules/ +/package-lock.json +```` + +## File: example/templates/airtable-map-block/airtable-map-block.php +````php +A Leaflet Map block in the block editor

-Filter the query variable name used for pagination (default: `rdb-pagination`). +

A Leaflet Map block in the WordPress frontend

-```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 ); -``` +## Build step -### remote_data_blocks_request_details +Because the custom block uses JSX, it requires a build step: `npm run build`. -Filter the request details (method, options, url) before the HTTP request is dispatched. +If you want to adapt this example code in your own codebase, we recommend using [the `@wordpress/create-block` utility](https://developer.wordpress.org/block-editor/reference-guides/packages/packages-create-block/) to scaffold your custom block. +```` -```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 ); -``` +## File: example/templates/google-sheets-block/google-sheets-block.php +````php + [ + '__version' => 1, + 'credentials' => $credentials, + 'display_name' => 'Westeros Houses', + 'spreadsheet' => [ + 'id' => $spreadsheet_id, + ], + 'sheets' => [ + [ + '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', + ], + ], + ], + ], + ], + ] ); - return $input_variables; -}, 10, 4 ); -``` + GoogleSheetsIntegration::register_blocks_for_google_sheets_data_source( $westeros_houses_data_source ); +} +add_action( 'init', 'register_google_sheets_remote_data_block' ); +```` -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. +## File: example/templates/shopify-product-block/shopify-product-block.php +````php + [ + '__version' => 1, + 'access_token' => '{{ Access Token }}', + 'display_name' => '{{ Shopify Store Display Name }}', + 'store_name' => '{{ store-name.myshopify.com }}', + ], + ] ); -```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'] ); - } + ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); +} +add_action( 'init', 'register_shopify_remote_data_block' ); +```` - return $input_variables; -}, 10, 4 ); -``` +## File: example/templates/theme/functions.php +````php +get( 'Version' ) + ); } -add_filter( 'remote_data_blocks_query_response_metadata', 'custom_query_response_metadata', 10, 3 ); -``` +add_action( 'wp_enqueue_scripts', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); +add_action( 'enqueue_block_assets', __NAMESPACE__ . '\\remote_data_blocks_example_theme_enqueue_block_styles', 15, 0 ); ```` -## File: docs/extending/index.md +## File: example/templates/theme/README.md ````markdown -# Extending - -> [!TIP] -> Make sure you've read the [core concepts](../concepts/index.md) behind Remote Data Blocks before extending the plugin. +# Remote Data Blocks Example Theme -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. +This folder contains a simple example theme that provides custom styling of Remote Data Blocks via a `theme.json` file. It is a child theme of `twentytwentyfour` and delegates all rendering to the parent theme. +```` -## Customization +## File: example/templates/theme/style-remote-data-blocks.css +````css +/** + * This file may also contain CSS overrides that are difficult or impossible to + * implement using `theme.json` alone. For example, each bound inner block of a + * Remote Data Block has a class name corresponding to the field it is bound to. + * + * Therefore, a Remote Data Block named "Shopify Product" containing a paragraph + * block bound to a field named `description` can be targeted with a selector: + * + * .wp-block-remote-data-blocks-shopify-product p.rdb-block-data-description { + * /* styles here * / + * } + */ -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. +.wp-block-remote-data-blocks-shopify-product p.rdb-block-data-price { + font-weight: 700; +} +```` -- [Data source](data-source.md) -- [Query](query.md) -- [Block registration](block-registration.md) +## File: example/templates/theme/style.css +````css +/*! + * Theme Name: Remote Data Blocks Example Theme + * Description: Example theme that provides styling for remote data blocks + * Version: 1.0.0 + * Template: twentytwentyfour + * Tags: remote-data-blocks + * Text Domain: remote-data-blocks + * Tested up to: 6.6 + * Requires at least: 6.6 + * Requires PHP: 8.1 + * License: GNU General Public License v2.0 + * License URI: https://www.gnu.org/licenses/gpl-2.0.html + */ -## Advanced customization +/* This file is not enqueued and exists only to provide the theme manifest. */ +```` -- [Block patterns](block-patterns.md) -- [Hooks (actions and filters)](hooks.md) -- [Overrides](overrides.md) +## File: example/templates/theme/theme.json +````json +{ + "$schema": "https://schemas.wp.org/trunk/theme.json", + "version": 3, + "settings": { + "blocks": { + "remote-data-blocks/conference-event": { + "custom": { + "remote-data-blocks": {} + } + } + } + }, + "styles": { + "elements": {}, + "blocks": { + "remote-data-blocks/conference-event": { + "color": { + "background": "#e9c9f9", + "text": "#290939" + }, + "css": "& p { margin: 0.25rem 0; }", + "shadow": "rgb(38, 57, 77) 0px 20px 30px -10px", + "spacing": { + "margin": { + "bottom": "2rem", + "top": "2rem" + }, + "padding": { + "bottom": "1.5rem", + "left": "1.5rem", + "right": "1.5rem", + "top": "1.5rem" + } + }, + "typography": { + "fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif", + "fontSize": "1.25rem" + }, + "elements": { + "heading": { + "border": { + "bottom": { + "color": "#593969", + "style": "solid", + "width": "3px" + } + }, + "color": { + "text": "#290939" + }, + "spacing": { + "margin": { + "top": "0.5rem" + }, + "padding": { + "bottom": "0.5rem" + } + }, + "typography": { + "fontFamily": "Inter, Helvetica Neue, Helvetica, Arial, sans-serif", + "fontSize": "2rem", + "fontWeight": "800" + } + } + } + }, + "remote-data-blocks/shopify-product": { + "css": "& .wp-block-columns { flex-direction: row-reverse }", + "spacing": { + "margin": { + "bottom": "2rem", + "top": "2rem" + }, + "padding": { + "bottom": "1.5rem", + "left": "1.5rem", + "right": "1.5rem", + "top": "1.5rem" + } + }, + "typography": { + "fontSize": "1rem" + }, + "elements": { + "heading": { + "color": { + "text": "#290939" + }, + "spacing": { + "margin": { + "top": "0.5rem" + }, + "padding": { + "bottom": "0.5rem" + } + }, + "typography": { + "fontFamily": "Helvetica Neue, Helvetica, Arial, sans-serif", + "fontSize": "1.25rem", + "fontWeight": "900" + } + } + } + } + } + } +} +```` -## Examples and AI prompts +## File: example/README.md +````markdown +# 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 - -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: - -- `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` - -#### Example - -```php -'input_schema' => [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', - ], -], -``` - -There are also some special input variable types: - -- `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. +# Create a remote data block using an HTTP data source -#### Example with search and pagination input variables +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. -```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', - ], -], -``` +## Create the data source -If omitted, `input_schema` defaults to an empty array. -```` +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. -## File: docs/troubleshooting.md -````markdown -# Troubleshooting and debugging +## Register the block -This plugin provides a [local development environment](local-development.md) with built-in debugging tools. +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. -## Query monitor +```php + [!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`. +function register_zip_code_remote_data_block(): void { + $zip_code_data_source = HttpDataSource::from_uuid( '{{ Data source UUID }}' ); -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. + if ( is_wp_error( $zip_code_data_source ) ) { + return; + } -## Debugging + $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', + ], + ], + ], + ]; -The [local development environment](local-development.md) includes Xdebug for debugging PHP code and a Node.js debugging port for debugging block editor scripts. + register_remote_data_block( [ + 'title' => 'Zip Code', + 'render_query' => [ + 'query' => $zip_code_query, + ], + ] ); +} +add_action( 'init', 'register_zip_code_remote_data_block' ); +``` -## Support +This 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. +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. -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). +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. -## Resetting config +## Insert the block -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. +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. -```sh -npm run wp-cli option delete remote_data_blocks_config -``` -```` +## Patterns and styling -## File: example/.cursor/rules/project-scope.mdc -```` ---- -description: Project scope -globs: -alwaysApply: true ---- +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). -- 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. -```` +Remote data blocks can also be styled with the block editor's style settings, `theme.json`, or custom stylesheets. -## File: example/blocks/github-markdown-block/inc/markdown-links.php -````php -' . $html; +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. - // Suppress errors due to malformed HTML - // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged - @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); +## Shopify API Access - // Create an XPath to query href attributes - $xpath = new DOMXPath( $dom ); +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. - // Query all elements with href attributes - $nodes = $xpath->query( '//*[@href]' ); - foreach ( $nodes as $node ) { - if ( ! $node instanceof DOMElement ) { - continue; - } - $href = $node->getAttribute( 'href' ); +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): - // 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 ); +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. - // Set the new href - $node->setAttribute( 'href', $new_href ); - } - } +If you are using a custom app created in Shopify's Dev Dashboard after January 1, 2026, follow Shopify's [Dev Dashboard access token guide](https://shopify.dev/docs/apps/build/dev-dashboard/get-api-access-tokens) to create and install the app, request the `unauthenticated_read_product_listings` Storefront API scope, and exchange your app credentials for an access token. Use that token with Shopify's [`storefrontAccessTokenCreate` mutation](https://shopify.dev/docs/api/admin-graphql/latest/mutations/storefrontAccessTokenCreate) to create the Storefront API access token for Remote Data Blocks. Do not paste the short-lived Admin API access token into Remote Data Blocks. Existing admin-created custom apps can continue using their existing Storefront API access tokens. - // 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 ); - } +## Create the data source - // Save and return the updated HTML without the XML declaration. - return preg_replace( '/^<\?xml[^>]+\?>/', '', $dom->saveHTML() ); -} +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. +If the credentials are correct, you can save the data source. If you receive an error, check the token and try again. -/** - * Adjusts the markdown file path by resolving relative paths to absolute paths. - * Preserves fragment identifiers (anchors) in the URL. - * - * @param string $path The original path. - * @param string $current_file_path The current file's path. - * @return string The adjusted path. - */ -function adjust_markdown_file_path( string $path, string $current_file_path = '' ): string { - global $post; - $page_slug = $post->post_name; +## Insert the block - // Parse the URL to separate the path and fragment - $parts = wp_parse_url( $path ); +Create or edit a page or post, then using the Block Inserter, search for the block using the name you provided in step four. - // Extract the path and fragment - $original_path = isset( $parts['path'] ) ? $parts['path'] : ''; - $fragment = isset( $parts['fragment'] ) ? '#' . $parts['fragment'] : ''; +![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) - // Get the directory of the current file - $current_dir = dirname( $current_file_path ); +## Patterns and styling - // 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 = []; +You can use patterns to create a consistent, reusable layout for your remote data. You can read more about [patterns](../extending/block-patterns.md). - foreach ( $parts as $part ) { - if ( '.' === $part || '' === $part ) { - continue; - } - if ( '..' === $part ) { - array_pop( $absolute_parts ); - } else { - $absolute_parts[] = $part; - } - } +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. - $absolute_path = implode( '/', $absolute_parts ); - } +## Code reference - // Remove the .md extension - $absolute_path = preg_replace( '/\.md$/', '', $absolute_path ); +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. - // Ensure the path starts with a forward slash and includes the page slug - return '/' . $page_slug . '/' . $absolute_path . $fragment; -} +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/github-markdown-block/github-markdown-block.php @@ -2535,7 +2893,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 +2919,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 +2968,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 +3047,105 @@ 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/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,642 @@ 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 + +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 -'output_schema' => [ - 'is_collection' => true, - 'path' => '$.data[*]', - 'type' => [ - 'id' => [ - 'name' => 'Art ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Art Title', - 'path' => '$.title', - 'type' => 'string', - ], - ], -], +'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. + +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 -'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', - ], - ], -], +class CustomQuery implements HttpQueryInterface, CacheKeyRequestHeadersAwareInterface { + // ... + + 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 feb34109..bddc7e47 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 7751df67..1d4ef6f5 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 d05883ce..c116323a 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/Query/CacheKeyRequestHeadersAwareInterface.php b/inc/Config/Query/CacheKeyRequestHeadersAwareInterface.php new file mode 100644 index 00000000..32fb8b40 --- /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 d63f4c7b..2fe657ef 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. @@ -38,6 +38,15 @@ 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 { + return $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 2058cacd..74f7cd08 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -4,8 +4,10 @@ use Exception; use GuzzleHttp\RequestOptions; +use RemoteDataBlocks\Config\Query\CacheKeyRequestHeadersAwareInterface; use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Editor\DataBinding\Pagination; +use RemoteDataBlocks\HttpClient\CacheKeyRequestHeaders; use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\HttpClient\RdbCacheStrategy; use WP_Error; @@ -57,6 +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 ); + $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 ) { @@ -91,7 +98,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 = [ + 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 new file mode 100644 index 00000000..35a331db --- /dev/null +++ b/inc/HttpClient/CacheKeyRequestHeaders.php @@ -0,0 +1,33 @@ + $headers Request header names to merge with DEFAULT_HEADERS. + * @return array Merged request header names. + */ + public static function merge( array $headers ): array { + $merged_headers = self::DEFAULT_HEADERS; + $seen_headers = array_fill_keys( + array_map( 'strtolower', self::DEFAULT_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 $merged_headers; + } +} diff --git a/inc/HttpClient/RdbCacheMiddleware.php b/inc/HttpClient/RdbCacheMiddleware.php index 88411f4b..2fe1f723 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( RdbCacheStrategy::without_cache_metadata_headers( $request ), $options ); + }; + + return parent::__invoke( $handler_without_cache_metadata ); + } + /** * @var array */ diff --git a/inc/HttpClient/RdbCacheStrategy.php b/inc/HttpClient/RdbCacheStrategy.php index e943d5a9..2e0363cb 100644 --- a/inc/HttpClient/RdbCacheStrategy.php +++ b/inc/HttpClient/RdbCacheStrategy.php @@ -5,45 +5,67 @@ 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-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'; - private const CACHE_INVALIDATING_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 + private const CACHE_METADATA_REQUEST_HEADERS = [ + self::CACHE_TTL_REQUEST_HEADER, + self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER, + ]; + 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 ) { - // Filter this if customization is needed. - $vary_headers = new KeyValueHttpHeader( self::CACHE_INVALIDATING_REQUEST_HEADERS ); + $this->storage = $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ); + } - parent::__construct( - $storage ?? new WordPressObjectCacheStorage( self::WP_OBJECT_CACHE_GROUP ), - self::FALLBACK_CACHE_TTL_IN_SECONDS, - $vary_headers - ); + public static function without_cache_metadata_headers( RequestInterface $request ): RequestInterface { + foreach ( self::CACHE_METADATA_REQUEST_HEADERS as $header ) { + $request = $request->withoutHeader( $header ); + } + + return $request; } 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(); + $cache_key_request_headers = CacheKeyRequestHeaders::merge( + $request->getHeader( self::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) + ); + $cache_headers = []; - foreach ( self::CACHE_INVALIDATING_REQUEST_HEADERS as $header ) { - if ( isset( $request_headers[ $header ] ) ) { - $cache_headers[ $header ] = $request_headers[ $header ]; + foreach ( $cache_key_request_headers as $header ) { + if ( $request->hasHeader( $header ) ) { + $cache_headers[ $header ] = $request->getHeader( $header ); } } @@ -57,20 +79,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 ) ); } - 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 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 + ); + } + + 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; } @@ -81,11 +125,10 @@ 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. - $response = $response->withoutHeader( 'Etag' )->withoutHeader( 'Last-Modified' ); - return new CacheEntry( $request->withoutHeader( static::HEADER_TTL ), $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); + $cache_request = self::without_cache_metadata_headers( $request ); + + return new CacheEntry( $cache_request, $response, new DateTime( sprintf( '%+d seconds', $ttl ) ) ); } } diff --git a/inc/Integrations/Shopify/ShopifyIntegration.php b/inc/Integrations/Shopify/ShopifyIntegration.php index e980a88b..aa148eb0 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 0aedb9d8..8b443464 100644 --- a/inc/Validation/ConfigSchemas.php +++ b/inc/Validation/ConfigSchemas.php @@ -159,6 +159,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/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index ded8d435..b5b76c6b 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -3,10 +3,14 @@ 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; 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; @@ -93,6 +97,71 @@ public function testExecuteSuccessfulRequest( string $endpoint ): void { $this->assertArrayHasKey( 'results', $result ); } + public function testRequestDetailsIncludeCacheKeyRequestHeadersHeader(): void { + $data_source = MockDataSource::create(); + $this->assertInstanceOf( MockDataSource::class, $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 { + 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'][ RequestOptions::HEADERS ][ RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ] ?? null + ); + } + + 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 ); + + $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/Config/QueryTest.php b/tests/inc/Config/QueryTest.php index 8cd69ea7..92730903 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 testCacheKeyRequestHeadersAreQueryOnly(): 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( + [ 'x-api-key', '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 cb59b3de..bbae9e42 100644 --- a/tests/inc/HttpClient/HttpClientTest.php +++ b/tests/inc/HttpClient/HttpClientTest.php @@ -6,7 +6,10 @@ use GuzzleHttp\Client; 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; @@ -259,6 +262,165 @@ 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' ), + new Response( 200, [], 'Second Response' ) + ); + + $first_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ + 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' => [ + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'Authorization', 'Cache-Control', 'X-Api-Key' ], + '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 testConfiguredCustomHeaderNameIsCaseInsensitive(): void { + $this->mock_handler->append( + new Response( 200, [], 'First Response' ), + new Response( 200, [], 'Second Response' ) + ); + + $first_response = $this->http_client->request( 'GET', '/test', [ + 'headers' => [ + 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' => [ + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], + '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 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', [ + 'headers' => [ + 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( RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER ) ); + } + + public function testCacheTtlMetadataIsNotSentToRequestHandler(): 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', [ + 'headers' => [ + RdbCacheStrategy::CACHE_TTL_REQUEST_HEADER => 600, + ], + ], $client ); + + $this->assertCount( 1, $transactions ); + $this->assertFalse( $transactions[0]['request']->hasHeader( RdbCacheStrategy::CACHE_TTL_REQUEST_HEADER ) ); + } + + public function testCacheKeyRequestHeaderMetadataIsNotStoredInCacheEntry(): void { + $storage = new VolatileRuntimeStorage(); + $strategy = new RdbCacheStrategy( $storage ); + $request = new Request( 'GET', 'https://example.com/test', [ + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_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( 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( diff --git a/tests/inc/HttpClient/RdbLogMiddlewareTest.php b/tests/inc/HttpClient/RdbLogMiddlewareTest.php new file mode 100644 index 00000000..8c909d9b --- /dev/null +++ b/tests/inc/HttpClient/RdbLogMiddlewareTest.php @@ -0,0 +1,49 @@ + [ 'X-Api-Key' ], + 'X-Api-Key' => 'first-api-key', + ] ), + $first_options + )->wait(); + + $second_options = []; + $log_handler( + new Request( 'GET', 'https://example.com/data', [ + RdbCacheStrategy::CACHE_KEY_REQUEST_HEADERS_REQUEST_HEADER => [ 'X-Api-Key' ], + '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'] ); + } +} diff --git a/tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php b/tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php new file mode 100644 index 00000000..6e248b37 --- /dev/null +++ b/tests/inc/Integrations/Shopify/ShopifyIntegrationTest.php @@ -0,0 +1,31 @@ + [ + '__version' => 1, + 'access_token' => 'secret', + 'display_name' => 'Shopify Store', + 'store_name' => 'example', + ], + ] ); + + $this->assertNotInstanceOf( WP_Error::class, $data_source ); + $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/LegacyHttpQuery.php b/tests/inc/Mocks/LegacyHttpQuery.php new file mode 100644 index 00000000..2a2cfed1 --- /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; + } +} diff --git a/tests/inc/Mocks/MockQuery.php b/tests/inc/Mocks/MockQuery.php index 3f83606b..648be719 100644 --- a/tests/inc/Mocks/MockQuery.php +++ b/tests/inc/Mocks/MockQuery.php @@ -13,6 +13,8 @@ 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', 'endpoint' => $config['endpoint'] ?? null,