diff --git a/README.md b/README.md index 5b2445e6..255fc0ab 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Launch in WordPress Playground](https://img.shields.io/badge/Launch%20in%20WordPress%20Playground-blue?style=for-the-badge)](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/blueprint.json) -[Launch the plugin in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/blueprint.json) and explore. An example API ("Conference Events") is included, or visit Settings > Remote Data Blocks to add your own. Visit the [workflows guide](docs/workflows/index.md) to dive in. +[Launch the plugin in WordPress Playground](https://playground.wordpress.net/?blueprint-url=https://raw.githubusercontent.com/Automattic/remote-data-blocks/trunk/blueprint.json) and explore. An example API ("Conference Event") is included, or visit Settings > Remote Data Blocks to add your own. Visit the [workflows guide](docs/workflows/index.md) to dive in. ## Installation diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 51c6ead4..a1267451 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -18,17 +18,19 @@ Remote data blocks are custom blocks, but they are created and registered by our ## Data sources and queries -Each remote data block is associated with a **data source** and a **query** that defines how data is fetched, processed, and displayed. Simple data sources and queries can be configured via the plugin's settings screen, while others may require custom PHP code (see [extending](../extending/index.md)). +Each remote data block is associated with at least one **query** that defines how data is fetched, processed, and displayed. Queries delegate some logic to a **data source**, which can be reused by multiple queries. + +Simple data sources and queries can be configured via the plugin's settings screen, while others may require custom PHP code (see [extending](../extending/index.md)). ## Data fetching -Data fetching is handled by the plugin and wraps `wp_remote_request`. When a request to your site resolves to one or more remote data blocks, the remote data will be fetched and potentially cached by our plugin. Multiple requests for the same data will be deduped, even if the requests are not cacheable. +Data fetching is handled by the plugin and wraps `wp_remote_request`. When a request to your site resolves to one or more remote data blocks, the remote data will be fetched and potentially cached by our plugin. Multiple requests for the same data within a single page load will be deduped, even if the requests are not cacheable. ### Caching -The plugin offers a caching layer for optimal performance and to help avoid rate limiting from remote data sources. If your WordPress environment has configured a [persistent object cache](https://developer.wordpress.org/reference/classes/wp_object_cache/#persistent-cache-plugins), it will be used. Otherwise, the plugin will utilize in-memory (per-request) caching. Deploying to production without a persistent object cache is not recommended. +The plugin offers a caching layer for optimal performance and to help avoid rate limiting from remote data sources. If your WordPress environment has configured a [persistent object cache](https://developer.wordpress.org/reference/classes/wp_object_cache/#persistent-cache-plugins), it will be used. 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 60 seconds, but can be adjusted by extending the query class and [overriding the `get_cache_ttl` method](../extending/query.md#get_cache_ttl). +The default TTL for all cache objects is 60 seconds, but it can be [configured per query or per request](../extending/query.md#get_cache_ttl). ## Theming diff --git a/docs/extending/block-patterns.md b/docs/extending/block-patterns.md index bc92b9f6..b8887e29 100644 --- a/docs/extending/block-patterns.md +++ b/docs/extending/block-patterns.md @@ -1,6 +1,6 @@ # Block patterns -Patterns allow you to represent your remote data if different ways. By default, the plugin registers a unstyled block pattern that you can use out of the box. You can create additional patterns in the WordPress Dashboard or programmatically using the `register_remote_data_block_pattern` function. +Patterns allow you to represent your remote data if different ways. By default, the plugin registers a unstyled block pattern that you can use out of the box. You can create additional patterns in the WordPress Dashboard or programmatically by passing a `patterns` property to your block options. Example: @@ -18,11 +18,14 @@ Example: ``` ```php -function register_your_block_pattern() { - $block_name = 'Your Custom Block'; - $block_pattern = file_get_contents( '/path/to/your-pattern.html' ); - - register_remote_data_block_pattern( $block_name, 'Pattern Title', $block_pattern ); -} -add_action( 'init', 'YourNamespace\\register_your_block_pattern', 10, 0 ); +register_remote_data_block( [ + 'title' => 'My Remote Data Block', + 'queries' => [ /* ... */ ], + 'patterns' => [ + [ + 'title' => 'My Pattern', + 'content' => file_get_contents( __DIR__ . '/my-pattern.html' ), + ], + ], +] ); ``` diff --git a/docs/extending/block-registration.md b/docs/extending/block-registration.md index cdfee2f2..e3ef2254 100644 --- a/docs/extending/block-registration.md +++ b/docs/extending/block-registration.md @@ -1,14 +1,47 @@ # Block registration -Use the `register_remote_data_block` function to register your block and associate it with your query and data source. +Use the `register_remote_data_block` function to register your block and associate it with your query and data source. This example: + +1. Creates a data source +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. ```php function register_your_custom_block() { - $block_name = 'Your Custom Block'; - $your_data_source = new YourCustomDataSource(); - $your_query = new YourCustomQuery( $your_data_source ); + $data_source = HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Example API', + 'endpoint' => 'https://api.example.com/', + ], + ] ); + + $display_query = HttpQuery::from_array( [ + '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', + ], + ], + ], + ] ); - register_remote_data_block( $block_name, $your_query ); + register_remote_data_block( [ + 'title' => 'My Block', + 'queries' => [ + 'display' => $display_query, + ], + ] ); } add_action( 'init', 'YourNamespace\\register_your_custom_block', 10, 0 ); ``` diff --git a/docs/extending/data-source.md b/docs/extending/data-source.md index c092a3ba..46756bc9 100644 --- a/docs/extending/data-source.md +++ b/docs/extending/data-source.md @@ -1,59 +1,41 @@ # Data source -A data source defines basic reusable properties of an API and is required to define a [query](query.md). - -## DataSourceInterface - -At its simplest, a data source implements `DataSourceInterface` and describes itself with the following methods: - -- `get_display_name(): string`: Return the display name of the data source. -- `get_image_url(): string|null`: Optionally, return an image URL that can represent the data source in UI. - -## HttpDataSource - -The `HttpDataSource` class implements `DataSourceInterface` and provides common reusable properties of an HTTP API: - -- `get_endpoint(): string`: Returns the base URL of the API endpoint. This can be overridden by a query. -- `get_request_headers(): array`: Returns an associative array of HTTP headers to be sent with each request. This is a common place to set authentication headers such as `Authorization`. This array can be extended or overridden by a query. +A data source defines basic reusable properties of an API and is used by a [query](query.md) to reduce boilerplate. It allows helps this plugin represent your data source in the plugin settings screen and other UI. ## Example -Most HTTP-powered APIs can be represented by defining a class that extends `HttpDataSource`. Here's an example of a data source for US ZIP code data: +Most HTTP-powered APIs can be represented by defining a class that extends `HttpDataSource`. Here's an example of a data source for an example HTTP API: ```php -class ZipCodeDataSource extends HttpDataSource { - public function get_display_name(): string { - return 'US ZIP codes'; - } - - public function get_endpoint(): string { - return 'https://api.zippopotam.us/us/'; - } - - public function get_request_headers(): array|WP_Error { - return [ +$data_source = HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Example API', + 'endpoint' => 'https://api.example.com/', + 'request_headers' => [ 'Content-Type' => 'application/json', - ]; - } -} + 'X-Api-Key': MY_API_KEY_CONSTANT, + ], + ], +] ); ``` -The logic to fetch data from the API is defined in a [query](query.md). +The configuration array passed to `from_array` is very flexible, so it's usually not necessary to extend `HttpDataSource`, but you can do so if you need to add custom behavior. -## Custom data source +## Custom data sources -APIs that do not use HTTP as transport may require a custom data source. Implement `DataSourceInterface` and provide methods that define reusable properties of your API. The actual implementation of your transport will likely be provided via a [custom query runner](./query-runner.md). +For APIs that use non-HTTP transports, you can also implement `DataSourceInterface` and provide methods that define reusable properties of your API. The actual implementation of your transport will need to be provided by a [custom query runner](./query-runner.md). -Here is an example of a data source for a WebDAV server: +Here is a theoretical example of a data source for a WebDAV server: ```php class WebDavFilesDataSource implements DataSourceInterface { public function get_display_name(): string { - return 'WebDAV Files'; + return 'My WebDAV Files'; } - public function get_image_url(): null { - return null; + public function get_image_url(): string { + return 'https://example.com/webdav-icon.png'; } public function get_webdav_root(): string { diff --git a/docs/extending/hooks.md b/docs/extending/hooks.md index b1558c2c..29ab65d5 100644 --- a/docs/extending/hooks.md +++ b/docs/extending/hooks.md @@ -36,7 +36,7 @@ add_filter( 'remote_data_blocks_register_example_block', '__return_false' ); Filter the allowed URL schemes for this request. By default, only HTTPS is allowed, but it might be useful to relax this restriction in local environments. ```php -function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryContext $query_context ): array { +function custom_allowed_url_schemes( array $allowed_url_schemes, HttpQueryInterface $query ): array { // Modify the allowed URL schemes. return $allowed_url_schemes; } @@ -48,7 +48,7 @@ add_filter( 'remote_data_blocks_allowed_url_schemes', 'custom_allowed_url_scheme Filter the request details (method, options, url) before the HTTP request is dispatched. ```php -function custom_request_details( array $request_details, HttpQueryContext $query_context, array $input_variables ): array { +function custom_request_details( array $request_details, HttpQueryInterface $query, array $input_variables ): array { // Modify the request details. return $request_details; } @@ -60,7 +60,7 @@ add_filter( 'remote_data_blocks_request_details', 'custom_request_details', 10, Filter the query response metadata, which are available as bindings for field shortcodes. 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, HttpQueryContext $query_context, array $input_variables ): array { +function custom_query_response_metadata( array $metadata, HttpQueryInterface $query, array $input_variables ): array { // Modify the response metadata. return $metadata; } diff --git a/docs/extending/index.md b/docs/extending/index.md index 13fc3bf0..cb75adac 100644 --- a/docs/extending/index.md +++ b/docs/extending/index.md @@ -9,14 +9,13 @@ Data sources and queries can be configured on the settings screen, but sometimes Here's a short overview of how data flows through the plugin when a post with a remote data block is rendered: -1. WordPress core loads the post content, parses the blocks, and recognizes that a paragraph block has a [block binding](https://make.wordpress.org/core/2024/03/06/new-feature-the-block-bindings-api/). +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 to execute. -4. The query runner is loaded: `$query->get_query_runner()`. -5. The query runner executes the query: `$query_runner->execute()`. -6. 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()`). -7. The query is dispatched and the response data is inspected, formatted into a consistent shape, and returned to the block binding callback function. -8. The callback function extracts the requested field from the response data and returns it to WordPress core for rendering. +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()` (usually by delegating to a [query runner](./query-runner.md)). +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. ## Customization diff --git a/docs/extending/query-runner.md b/docs/extending/query-runner.md index c2c36162..2b1cbb3b 100644 --- a/docs/extending/query-runner.md +++ b/docs/extending/query-runner.md @@ -1,43 +1,35 @@ # Query runner -A query runner executes a query and processes the results. The default `QueryRunner` used by the [`HttpQueryContext` class](query.md#HttpQueryContext) is designed to work with most APIs that transact over HTTP and return JSON, but you may want to provide a custom query runner if: +A query runner executes a query and processes the results. The default `QueryRunner` used by the [`HttpQuery` class](query.md) is designed to work with most APIs that transact over HTTP and return JSON, but you may want to provide a custom query runner if: - Your API does not respond with JSON or requires custom deserialization logic. - Your API uses a non-HTTP transport. - You want to implement custom processing of the response data that is not possible with the provided filters. -## QueryRunner +## Custom QueryRunner for HTTP queries -If your API transacts over HTTP and you want to customize the query runner, consider extending the `QueryRunner` class and overriding select methods. +If your API transacts over HTTP and you want to customize the query runner, consider extending the `QueryRunner` class and providing an instance to your query via the `query_runner` option. Here are the methods: -### execute( array $input_variables ): array|WP_Error +### execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error The `execute` method executes the query and returns the parsed data. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). -### get_request_details( array $input_variables ): array|WP_Error +### deserialize_response( string $raw_response_data, array $input_variables ): mixed + +By default, the `deserialize_response` assumes a JSON string and deserializes it using `json_decode`. Override this method to provide custom deserialization logic. + +### get_request_details( HttpQueryInterface $query, array $input_variables ): array|WP_Error The `get_request_details` method extracts and validates the request details provided by the query. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the HTTP method, request options, origin, and URI. -### get_raw_response_data( array $input_variables ): array|WP_Error +### get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error The `get_raw_response_data` method dispatches the HTTP request and assembles the raw (pre-processed) response data. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). The return value is an associative array that provides the response metadata and the raw response data. -### get_response_metadata( array $response_metadata, array $query_results ): array +### get_response_metadata( HttpQueryInterface $query, array $response_metadata, array $query_results ): array The `get_response_metadata` method returns the response metadata for the query, which are available as bindings for [field shortcodes](field-shortcodes.md). -### map_fields( string|array|object|null $response_data, bool $is_collection ): ?array - -The `map_fields` method maps fields from the API response data, adhering to the output schema defined by the query. - -### get_field_value( array|string $field_value, string $default_value = '', string $field_type = 'string' ): string - -The `get_field_value` method computes the field value based on the field type. Overriding this method can be useful if you have custom field types and want to format the value in a specific way (e.g., a custom date format). +## Custom query execution -## QueryRunnerInterface - -If you want to implement a query runner from scratch, `QueryRunnerInterface` requires only a single method, `execute`: - -### execute( array $input_variables ): array - -The `execute` method executes the query and returns the parsed data. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). +If your API uses a non-HTTP transport or you want full control over query execution, you should implement your own query that implements `QueryInterface` and provides a custom `execute` method. diff --git a/docs/extending/query.md b/docs/extending/query.md index e514d83e..dfb9480b 100644 --- a/docs/extending/query.md +++ b/docs/extending/query.md @@ -1,197 +1,211 @@ # Query -A query defines a request for data from a [data source](data-source.md) and makes that data available to a remote data block. A query defines input and output variables so that the Remote Data Blocks plugin knows how to interact with it. +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. -## HttpQueryContext +## HttpQuery -Most HTTP-powered APIs can be queried by defining a class that extends `HttpQueryContext`. Here's an example of a query for US ZIP code data: +Most HTTP-powered APIs can be queried using an `HttpQuery`. Here's an example of a query for US ZIP code data: ```php -class GetZipCodeQuery extends HttpQueryContext { - public function get_input_schema(): array { - return [ +$data_source = HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Zip Code API', + 'endpoint' => 'https://api.zippopotam.us/us/', + ], +] ); + +$query = HttpQuery::from_array( [ + 'display_name' => 'Get location by Zip code', + 'data_source' => $data_source, + 'endpoint' => function( array $input_variables ) use ( $data_source ): string { + return $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', ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - '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', - ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', + 'type' => 'string', ], - ]; - } - - public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . $input_variables['zip_code']; - } -} + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], +] ); ``` -The `get_input_schema` method defines the input data expected by the query. For some queries, input variables might be used to construct a request body, but in this case the `zip_code` input variable is used to customize the query endpoint via the `get_endpoint()` method. +- 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 expected by the query. For some queries, input variables might be used to construct a request body, but 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. The `path` property uses [JSONPath](https://jsonpath.com/) expressions to allow concise, no-code references to nested data. -The `get_output_schema` method defines how to extract data from the API response. The `path` property uses [JSONPath](https://jsonpath.com/) expressions to allow concise, no-code references to nested data. +This example features a small subset of the customization available for a query; see the full documentation below for details. -This example features a snall subset of the customization available for a query; see the full documentation below for details. +## HttpQuery configuration -## HttpQueryContext documentation +### display_name: string (required) -### VERSION +The `display_name` property defines the human-friendly name of the query. -The `VERSION` constant defines the current semver of `HttpQueryContext`. It is currently ignored but in the future may be used to navigate breaking changes. +### data_source: HttpDataSourceInterface (required) -### get_input_schema(): array +The `data_source` property provides the [data source](./data-source.md) used by the query. -The `get_input_schema` method defines the input data expected by the query. The method should return 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: +### endpoint: string|callable -- `name` (optional): The human-friendly display name of the input variable -- `default_value` (optional): The default value for the input variable. -- `overrides` (optional): An array of possible [overrides](overrides.md) for the input variable. Each override is an associative array with the following keys: - - `type`: The type of the override. Supported values are `query_var` and `url`. - - `target`: The targeted entity for the override (e.g., the query or URL variable that contains the overridde). -- `type` (required): The type of the input variable. Supported types are: - - `number` - - `string` - - `id` +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. #### Example ```php -public function get_input_schema(): array { - return [ - 'zip_code' => [ - 'name' => 'Zip Code', - 'type' => 'string', - ], - ]; -} +'endpoint' => function( array $input_variables ) use ( $data_source ): string { + return $data_source->get_endpoint() . $input_variables['zip_code']; +}, ``` -The default implementation returns an empty array. +### input_schema: array -### get_output_schema(): array +The `input_schema` property defines the input variables expected by the query. The method should return 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: -The `get_output_schema` method defines how to extract data from the API response. The method should return an associative array with the following structure: - -- `is_collection` (optional, default `false`): A boolean indicating whether the response data is a collection. If false, only a single item will be returned. -- `mappings` (required): An associative array of output variable definitions. The keys of the array are machine-friendly output variable names and the values are associative arrays with the following structure: - - `name` (optional): The human-friendly display name of the output variable. - - `default_value` (optional): The default value for the output variable. - - `path` (required): A [JSONPath](https://jsonpath.com/) expression to extract the variable value. - - `type` (required): The type of the output variable. Supported types are - - - `id` - - `base64` - - `boolean` - - `number` - - `string` - - `button_url` - - `image_url` - - `image_alt` - - `currency` - - `markdown` +- `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 -public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - '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', - ], - ], - ]; -} +'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], +], ``` -The default implementation returns an empty array. - -### get_data_source(): DataSourceInterface +If omitted, it defaults to an empty array. -The `get_data_source` method returns the data source associated with the query. By default, this method returns the data source that was provided to the class constructor. In most instances, you should not need to override this method. +### output_schema: array (required) -### get_endpoint( array $input_variables ): string +The `output_schema` property defines how to extract data from the API response. The method should return an associative array with the following structure: -By default, the `get_endpoint` method proxies to the `get_endpoint` method of query's data source. Override this method to set a custom endpoint for the query—for example, to construct the endpoints using an input variable. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). +- `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`. +- `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. +- `type` (required): A primitive type (e.g., `string`, `boolean`) or a nested output schema. Accepted primitive types are: + - `boolean` + - `button_url` + - `email_address` + - `html` + - `id` + - `image_alt` + - `image_url` + - `integer` + - `markdown` + - `null` + - `number` + - `string` + - `url` + - `uuid` #### Example ```php -public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . $input_variables['zip_code']; -} +'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 $response_data ): string { + return $response_data[0]['place name'] . ', ' . $response_data[0]['state']; + }, + 'type' => 'string', + ], + ], +], ``` -### get_image_url(): string|null - -By default, the `get_image_url` method proxies to the `get_image_url` method of the query's data source. Override this method to provide an image URL that will represent the query in the UI. +### request_method: string -### get_request_method(): string +The `request_method` property defines the HTTP request method used by the query. By default, it is `'GET'`. -By default, `get_request_method` returns `'GET'`. Override this method if your query uses a different HTTP request method. +### request_headers: array|callable -### get_request_headers( array $input_variables ): array - -By default, the `get_request_headers` method proxies to the `get_request_headers` method of the query's data source. Override this method to provide custom request headers for the query. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). +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. ### Example ```php -public function get_request_headers( array $input_variables ): array|WP_Error { +'request_headers' => function( array $input_variables ) use ( $data_source ): array { return array_merge( - $this->get_data_source()->get_request_headers(), - [ 'X-Product-ID' => $input_variables['product_id'] ] + $data_source->get_request_headers(), + [ 'X-Foo' => $input_variables['foo'] ] ); -} +}, ``` -### get_request_body( array $input_variables ): array|null +### 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. -Override this method to define a request body for this query. The return value will be converted to JSON using `wp_json_encode`. The input variables for the current request are provided as an associative array (`[ $var_name => $value ]`). +### cache_ttl: int|null|callable -### get_query_name(): string +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 ]`). -Override this method to specify a name that represents the query in UI. +A value of `-1` indicates the query should not be cached. A value of `null` indicates the default TTL should be used (60 seconds). If omitted, the default TTL is used. -### get_query_runner(): QueryRunnerInterface +Remote data blocks utilize the WordPress object cache (`wp_cache_get()` / `wp_cache_set()`) for response caching. Ensure a persistent object cache plugin is provided by your platform or installed for this value to be respected. -Override this method to specify a custom [query runner](query-runner.md) for this query. The default query runner works well with most HTTP-powered APIs. +### image_url: string|null -### process_response( string $raw_response_data, array $input_variables ): string|array|object|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. -The default query runner assumes a JSON response and decodes it. If you need to implement custom deserialization or want to process the response in some way before the output variables are extracted, override this method. The mappings and JSONPath expressions defined by `get_output_schema` will be applied to the return value of this method. +### preprocess_response: callable + +If you need to prerocess the response in some way before the output variables are extracted, provide a `preprocess_response` function. The function will receive the deserialized response. + +#### Example + +```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 ] + ); +}, +``` -## QueryContextInterface +### query_runner: QueryRunnerInterface -The `QueryContextInterface` interface defines the methods that must be implemented by a query class. If you have highly custom requirements that cannot be met by `HttpQueryContext`, you can implement `QueryContextInterface` directly. +Use the `query_runner` property to provide a custom [query runner](./query-runner.md) for the query. If omitted, the query will use the default query runner, which works well with most HTTP-powered APIs. diff --git a/docs/local-development.md b/docs/local-development.md index a9e04dce..80010e66 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -19,7 +19,7 @@ npm run dev This will spin up a WordPress environment and a Valkey (Redis) instance for object cache. It will also build the block editor scripts, watch for changes, and open a Node.js debugging port. The WordPress environment will be available at `http://localhost:8888` (admin user: `admin`, password: `password`). -Stop the development environment with `Ctrl+C` and resume it by running the same command. You can also manually stop the environment with `npm run stop`. Stopping the environment stops the WordPress containers but preserves their state. +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. ### Testing @@ -56,7 +56,7 @@ npm run wp-cli option get siteurl Destroy your local environment and irreversibly delete all content, configuration, and data: ```sh -npm run destroy +npm run dev:destroy ``` ## Local playground diff --git a/docs/workflows/airtable-with-code.md b/docs/workflows/airtable-with-code.md deleted file mode 100644 index 911b4aa9..00000000 --- a/docs/workflows/airtable-with-code.md +++ /dev/null @@ -1,224 +0,0 @@ -# Create an Airtable remote data block - -This page will walk you through connecting an [Airtable](https://airtable.com/) data source, registering a remote data block to display table records, and customizing the styling of that block. It will require you to commit code to a WordPress theme or plugin. If you have not yet installed and activated the Remote Data Blocks plugin, visit [Getting Started](https://remotedatablocks.com/getting-started/). - -## Base and personal access token - -First, identify an Airtable base and table that you would like to use as a data source. This example uses a base created from the default [“Event planning” template](https://www.airtable.com/templates/event-planning/exppdJtYjEgfmd6Sq), accessible from the Airtable home screen after logging in. We will target the “Schedule” table from that base. - -

airtable-template

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

create-pat

- -This personal access token is a secret that should be provided to your application securely. On WordPress VIP, we recommend using [environment variables](https://docs.wpvip.com/infrastructure/environments/manage-environment-variables/) to provide this token. The code in this example assumes that the token has been provided securely via a constant named `AIRTABLE_EVENTS_ACCESS_TOKEN`. - -## Define the data source - -In a file in your theme code, create a data source that provides the personal access token, the Airtable base ID, and the table ID (see ["Finding Airtable IDs"](https://support.airtable.com/docs/finding-airtable-ids#finding-base-url-ids)). The Remote Data Blocks plugin provides an AirtableDataSource class that makes this easier than defining a data source from scratch [link tk]: - -```php -/* register-conference-event-block.php */ - -use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; - -$access_token = AIRTABLE_EVENTS_ACCESS_TOKEN; -$base_id = 'base-id'; -$table_id = 'table-id'; - -$airtable_data_source = new AirtableDataSource( $access_token, $base_id, $table_id ); -``` - -This data source provides basic details needed to communicate with the Airtable API. - -## Define a query - -Next, define a query that describes the data that you want to extract from the data source—in this example, a single record from the table. - -```php -/* class-airtable-get-event-query.php */ - -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; - -class AirtableGetEventQuery extends HttpQueryContext { - public function get_input_schema(): array { - return [ - 'record_id' => [ - 'name' => 'Record ID', - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - /** - * Airtable API endpoint for fetching a single table record. - */ - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/' . $input_variables['record_id']; - } -} -``` - -See Defining a query [link tk] for more information on input variables, output variables, and other query properties. In short, this query describes what input it needs (a record ID) and the data it returns (ID, title, location, and type). Note that the [Airtable "Get record" API endpoint](https://airtable.com/developers/web/api/get-record) includes the record ID in the URL, so you must override the `get_endpoint` method to build the URL at request time. - -## Register a block - -Now that you have a data source and query defined, you can register a WordPress block to display your remote data. Here's the full example: - -```php -/* register-conference-event-block.php */ - -use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; - -// AirtableGetEventQuery -require_once __DIR__ . '/class-airtable-get-event-query.php'; - -function register_conference_event_block() { - $block_name = 'Conference Event'; - $access_token = AIRTABLE_EVENTS_ACCESS_TOKEN; - $base_id = 'base-id'; - $table_id = 'table-id'; - - $data_source = new AirtableDataSource( $access_token, $base_id, $table_id ); - $get_event_query = new AirtableGetEventQuery( $data_source ); - - \register_remote_data_block( $block_name, $get_event_query ); -} - -add_action( 'init', __NAMESPACE__ . '\\register_conference_event_block' ); -``` - -That's it! The `register_remote_data_block` function takes care of registering the block in the Block Editor and providing UI to manage the block. - -## Insert the block - -Open a post for editing and select the block in the Block Inserter using the block name you provided ("Conference Event" in this example). - -https://github.com/user-attachments/assets/e37e5348-9bee-47bf-bebb-f7977e53f139 - -The default experience is basic. Since the query requires a record ID as input, the block provides a form to enter it manually. After the record is loaded, we can select a pattern to display the data; the plugin provides a simple, default pattern to use out-of-the-box. - -Next, let's work to make this default experience better. - -## Registering a list query - -Instead of requiring manual input of a record ID, you can enhance your remote data block to allow users to select a record from a list. Do this by creating and registering a list query: - -```php -/* class-airtable-list-events-query.php */ - -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; - -class AirtableListEventsQuery extends HttpQueryContext { - public function get_output_schema(): array { - return [ - 'root_path' => '$.records[*]', - 'is_collection' => true, - 'mappings' => [ - 'record_id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - public function get_query_name(): string { - return 'List events'; - } -} -``` - -This query accepts no input and returns a collection of events. **Important:** Since this query's output will be used as input for `AirtableGetEventQuery`, it needs to provide the input variables expected by that query (e.g., `record_id`). If it doesn't, the Remote Data Blocks Plugin will flag it as an error. - -Here's an updated example that registers this list query: - -```php -/* register-conference-event-block.php */ - -use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; - -// AirtableGetEventQuery -require_once __DIR__ . '/class-airtable-get-event-query.php'; - -// AirtableListEventsQuery -require_once __DIR__ . '/class-airtable-list-events-query.php'; - -function register_conference_event_block() { - $block_name. = 'Conference Event'; - $access_token = AIRTABLE_EVENTS_ACCESS_TOKEN; - $base_id = 'base-id'; - $table_id = 'table-id'; - - $data_source = new AirtableDataSource( $access_token, $base_id, $table_id ); - $get_event_query = new AirtableGetEventQuery( $data_source ); - - \register_remote_data_block( $block_name, $get_event_query ); - - $list_events_query = new AirtableListEventsQuery( $data_source ); - \register_remote_data_list_query( $block_name, $airtable_list_events_query ); -} - -add_action( 'init', __NAMESPACE__ . '\\register_conference_event_block' ); -``` - -The `register_remote_data_list_query` function registers `AirtableListEventsQuery` against the "Conference Event" block. This lets the Remote Data Blocks plugin know that it can be used to generate a list of items for the user to pick from: - -https://github.com/user-attachments/assets/67f22710-b1bd-4f2c-a410-2e20fe27b348 - -## Custom patterns and styling - -You can improve upon the default appearance of the block by creating your own patterns. Patterns can be associated with a remote data block in the "Pattern" settings in the sidebar of the pattern editor. Once associated with a remote data block, patterns will appear in the pattern selection modal. The Remote Data Blocks plugin supports both synced and unsynced patterns. - -https://github.com/user-attachments/assets/358d9d40-557b-4f39-b943-ed73d6f18adb - -Alternatively, you can alter the style of a remote data block using `theme.json` and / or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/theme) in the Remote Data Blocks GitHub repository for more details. - -## Code reference - -Check out [a working example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/airtable/events) of the concepts above in the Remote Data Blocks GitHub repository and feel free to open an issue if you run into any difficulty when registering or customizing your remote data blocks. diff --git a/docs/workflows/airtable.md b/docs/workflows/airtable.md new file mode 100644 index 00000000..ea56db50 --- /dev/null +++ b/docs/workflows/airtable.md @@ -0,0 +1,41 @@ +# Create an Airtable remote data block + +This page will walk you through connecting an [Airtable](https://airtable.com/) data source, registering a remote data block to display table records, and customizing the styling of that block. It will require you to commit code to a WordPress theme or plugin. If you have not yet installed and activated the Remote Data Blocks plugin, visit [Getting Started](https://remotedatablocks.com/getting-started/). + +## Base and personal access token + +First, identify an Airtable base and table that you would like to use as a data source. This example uses a base created from the default [“Event planning” template](https://www.airtable.com/templates/event-planning/exppdJtYjEgfmd6Sq), accessible from the Airtable home screen after logging in. We will target the “Schedule” table from that base. + +

airtable-template

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

create-pat

+ +This personal access token is a secret that should be provided to your application securely. On WordPress VIP, we recommend using [environment variables](https://docs.wpvip.com/infrastructure/environments/manage-environment-variables/) to provide this token. The code in this example assumes that the token has been provided securely via a constant named `AIRTABLE_EVENTS_ACCESS_TOKEN`. + +## Create the data source + +1. Go to the 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. Fill in the form to select your desired base, table, and fields. +5. Save the data source and return the data source list. + +## Insert the block + +Open a post for editing and select the block in the Block Inserter using the display name you provided. + +https://github.com/user-attachments/assets/67f22710-b1bd-4f2c-a410-2e20fe27b348 + +## Custom patterns and styling + +You can improve upon the default appearance of the block by creating your own patterns. Patterns can be associated with a remote data block in the "Pattern" settings in the sidebar of the pattern editor. Once associated with a remote data block, patterns will appear in the pattern selection modal. The Remote Data Blocks plugin supports both synced and unsynced patterns. + +https://github.com/user-attachments/assets/358d9d40-557b-4f39-b943-ed73d6f18adb + +Alternatively, you can alter the style of a remote data block using `theme.json` and / or custom stylesheets. See the [example child theme](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/theme) in the Remote Data Blocks GitHub repository for more details. + +## Code reference + +Check out [a working example](https://github.com/Automattic/remote-data-blocks/tree/trunk/example/airtable/events) of the concepts above in the Remote Data Blocks GitHub repository and feel free to open an issue if you run into any difficulty when registering or customizing your remote data blocks. diff --git a/docs/workflows/google-sheets-with-code.md b/docs/workflows/google-sheets.md similarity index 92% rename from docs/workflows/google-sheets-with-code.md rename to docs/workflows/google-sheets.md index 3fb0972a..85c17fae 100644 --- a/docs/workflows/google-sheets-with-code.md +++ b/docs/workflows/google-sheets.md @@ -22,12 +22,10 @@ The Service Account Keys JSON should be provided to your application securely. O ## Block Registration and Styling -This would be similar to the [Airtable workflow](airtable-with-code.md). Refer the following sections from that workflow - +This would be similar to the [Airtable workflow](airtable.md). Refer the following sections from that workflow: -- [Define a query](./airtable-with-code.md#define-a-query) -- [Register a block](./airtable-with-code.md#register-a-block) +- [Create the data source](./airtable.md#create-the-data-source) - [Insert the block](./airtable-with-code.md#insert-the-block) -- [Register a list query](./airtable-with-code.md#register-a-list-query) - [Custom patterns and styling](./airtable-with-code.md#custom-patterns-and-styling) ## Code Reference diff --git a/docs/workflows/index.md b/docs/workflows/index.md index 7d91fcbc..50fa03b1 100644 --- a/docs/workflows/index.md +++ b/docs/workflows/index.md @@ -1,9 +1,11 @@ # Workflows -## UI-based workflows +## UI-configured sources -## Code-based workflows +- [Create an Airtable integration](airtable.md) +- [Create a Google Sheets integration](google-sheets.md) +- [Create a REST API integration](rest-api.md) -- [Create an Airtable integration with code](airtable-with-code.md) -- [Create a Google Sheets integration with code](google-sheets-with-code.md) -- [Create a Zip Code integration with code contract](zip-code-with-contract.md) +## Code-configured sources + +- [Create a REST API integration with a code-configured data source](rest-api-with-code.md) diff --git a/docs/workflows/rest-api-with-code.md b/docs/workflows/rest-api-with-code.md new file mode 100644 index 00000000..01ca3532 --- /dev/null +++ b/docs/workflows/rest-api-with-code.md @@ -0,0 +1,67 @@ +# Create a remote data block using code + +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. If you have not yet installed and activated the Remote Data Blocks plugin, visit [Getting Started](https://remotedatablocks.com/getting-started/). + +Unlike the [UI-based example](rest-api.md), this example only uses code to define both the data source and query. + +## Register the block + +```php + [ + '__version' => 1, + 'display_name' => 'Zip Code API', + 'endpoint' => 'https://api.zippopotam.us/us/', + ], + ] ); + + $zipcode_query = HttpQuery::from_array( [ + 'data_source' => $zipcode_data_source, + 'endpoint' => function ( array $input_variables ) use ( $zipcode_data_source ): string { + return $zipcode_data_source->get_endpoint() . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ] ); + + register_remote_data_block( [ + 'title' => 'Zip Code', + 'queries' => [ + 'display' => $zipcode_query, + ], + ] ); +} +add_action( 'init', __NAMESPACE__ . '\\register_zipcode_block' ); +``` diff --git a/docs/workflows/rest-api.md b/docs/workflows/rest-api.md new file mode 100644 index 00000000..77cade90 --- /dev/null +++ b/docs/workflows/rest-api.md @@ -0,0 +1,81 @@ +# Create a remote data block using a data source defined in the UI + +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. If you have not yet installed and activated the Remote Data Blocks plugin, visit [Getting Started](https://remotedatablocks.com/getting-started/). + +## The contract + +Developers can use a UUID (v4) to define a "contract" between the remote data block integration they build and data sources defined in the Remote Data Blocks plugin settings screen. + +## Create the data source + +1. Go to the 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: + - Name: Zip Code API + - Endpoint: https://api.zippopotam.us/us/ +5. Save the data source and return the data source list. +6. In the actions column, click on the copy button to copy the data source's UUID to your clipboard. + +## Register the block + +In code, we'll define a query that uses the data source we just created using its UUID. + +```php + $zipcode_data_source, + 'endpoint' => function ( array $input_variables ) use ( $zipcode_data_source ): string { + return $zipcode_data_source->get_endpoint() . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ] ); + + register_remote_data_block( [ + 'title' => 'Zip Code', + 'queries' => [ + 'display' => $zipcode_query, + ], + ] ); +} +add_action( 'init', __NAMESPACE__ . '\\register_zipcode_block' ); +``` diff --git a/docs/workflows/zip-code-with-contract.md b/docs/workflows/zip-code-with-contract.md deleted file mode 100644 index 005f6323..00000000 --- a/docs/workflows/zip-code-with-contract.md +++ /dev/null @@ -1,110 +0,0 @@ -# Create a zip code remote data block - -This page will walk you through building [Zippopotam.us](https://zippopotam.us/) queries, registering a remote data block to display zip code information, and then connecting a data source later. It will require you to commit code to a WordPress theme or plugin. If you have not yet installed and activated the Remote Data Blocks plugin, visit [Getting Started](https://remotedatablocks.com/getting-started/). - -## The contract - -Developers can code a version 4 UUID to define a "contract" between the remote data block integration they build and the admins managing the Remote Data Blocks settings in WordPress. - -## Define a query - -First, we'll define a query that describes the data to extract from the Zippopotam.us zip code API. We create a class that extends `HttpQueryContext`: - -```php - [ - 'name' => 'Zip Code', - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - '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', - ], - ], - ]; - } - - public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . $input_variables['zip_code']; - } -} -``` - -This query describes what input it needs (a zip code) and the data it returns (zip code, city, and state). The get_endpoint method builds the URL for the API request using the provided zip code. - -## Register the block - -Now that we have a query defined, we can write the code to register a WordPress block to display the remote data. Here's the example: - -```php -debug( 'Zip Code data source not found' ); - return; - } - - $zipcode_query = new GetZipCodeQuery( $zipcode_data_source ); - - register_remote_data_block( 'Zip Code', $zipcode_query ); -} -add_action( 'init', __NAMESPACE__ . '\\register_zipcode_block' ); - -``` - -Note the `0d8f9e74-5244-49b4-981b-e5374107aa5c` UUID in the `GenericHttpDataSource::from_uuid` call. That's the "contract" in our implementation. - -We're done! - -## Later on - -An admin can seperately set up the data source via the following steps: - -1. Go to the Remote Data Blocks settings page in your WordPress admin area. -2. Click on the "Add" menu button. -3. Choose "HTTP" from the dropdown menu as the data source type. -4. Fill in the following details: - - Name: Zip Code API - - Endpoint: https://api.zippopotam.us/us/ -5. Save the data source. -6. Click on the edit icon for the newly saved data source. -7. Click on the settings icon next to "Edit HTTP Data Source". -8. Update the UUID to match the one defined in the code or vice versa. - -The UUID _must_ match the UUID defined by the developer in the previous section when creating the data source. diff --git a/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-locations-query.php b/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-locations-query.php deleted file mode 100644 index 80c4dfaa..00000000 --- a/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-locations-query.php +++ /dev/null @@ -1,53 +0,0 @@ - [ - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => '$.records[*]', - 'is_collection' => true, - 'mappings' => [ - 'id' => [ - 'name' => 'Location ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'map_name' => [ - 'name' => 'Name', - 'path' => '$.fields.Name', - 'type' => 'string', - ], - 'title' => [ - 'name' => 'Name', - 'path' => '$.fields.Name', - 'type' => 'string', - ], - 'x' => [ - 'name' => 'x', - 'path' => '$.fields.x', - 'type' => 'string', - ], - 'y' => [ - 'name' => 'y', - 'path' => '$.fields.y', - 'type' => 'string', - ], - ], - ]; - } - - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/tblc82R9msH4Yh6ZX?filterByFormula=FIND%28%27' . $input_variables['map_name'] . '%27%2C%20%7BMap%7D%29%3E0'; - } -} diff --git a/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-maps-query.php b/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-maps-query.php deleted file mode 100644 index 0e81f4cc..00000000 --- a/example/airtable/elden-ring-map/inc/queries/class-airtable-elden-ring-list-maps-query.php +++ /dev/null @@ -1,42 +0,0 @@ - [ - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => '$.records[*]', - 'is_collection' => true, - 'mappings' => [ - 'id' => [ - 'name' => 'Map ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'map_name' => [ - 'name' => 'Name', - 'path' => '$.fields.Name', - 'type' => 'string', - ], - ], - ]; - } - - public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/tblS3OYo8tZOg04CP'; - } - - public function get_query_name(): string { - return 'List maps'; - } -} diff --git a/example/airtable/elden-ring-map/register.php b/example/airtable/elden-ring-map/register.php index 0b2186c0..c33241d9 100644 --- a/example/airtable/elden-ring-map/register.php +++ b/example/airtable/elden-ring-map/register.php @@ -2,32 +2,115 @@ namespace RemoteDataBlocks\Example\Airtable\EldenRingMap; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; -use RemoteDataBlocks\Logging\LoggerManager; require_once __DIR__ . '/inc/interactivity-store/interactivity-store.php'; -require_once __DIR__ . '/inc/queries/class-airtable-elden-ring-list-locations-query.php'; -require_once __DIR__ . '/inc/queries/class-airtable-elden-ring-list-maps-query.php'; -function register_airtable_elden_ring_map_block() { +function register_airtable_elden_ring_map_block(): void { $block_name = 'Elden Ring Location'; $access_token = \RemoteDataBlocks\Example\get_access_token( 'airtable_elden_ring' ); if ( empty( $access_token ) ) { - $logger = LoggerManager::instance(); - $logger->warning( sprintf( '%s is not defined, cannot register %s block', 'EXAMPLE_AIRTABLE_ELDEN_RING_ACCESS_TOKEN', $block_name ) ); return; } - $elden_ring_data_source = AirtableDataSource::create( $access_token, 'appqI3sJ9R2NcML8Y', [], 'Elden Ring Locations' ); - $list_locations_query = new AirtableEldenRingListLocationsQuery( $elden_ring_data_source ); - $list_maps_query = new AirtableEldenRingListMapsQuery( $elden_ring_data_source ); + $elden_ring_data_source = AirtableDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => $access_token, + 'base' => [ + 'id' => 'appqI3sJ9R2NcML8Y', + 'name' => 'Elden Ring Locations', + ], + 'display_name' => 'Elden Ring Locations', + 'tables' => [], // AirtableDataSource does not formally provide queries. + ], + ] ); - register_remote_data_block( $block_name, $list_locations_query ); - register_remote_data_list_query( $block_name, $list_maps_query ); + $list_locations_query = HttpQuery::from_array( [ + 'data_source' => $elden_ring_data_source, + 'endpoint' => function ( array $input_variables ) use ( $elden_ring_data_source ) { + return $elden_ring_data_source->get_endpoint() . '/tblc82R9msH4Yh6ZX?filterByFormula=FIND%28%27' . $input_variables['map_name'] . '%27%2C%20%7BMap%7D%29%3E0'; + }, + 'input_schema' => [ + 'map_name' => [ + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.records[*]', + 'type' => [ + 'id' => [ + 'name' => 'Location ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'map_name' => [ + 'name' => 'Name', + 'path' => '$.fields.Name', + 'type' => 'string', + ], + 'title' => [ + 'name' => 'Name', + 'path' => '$.fields.Name', + 'type' => 'string', + ], + 'x' => [ + 'name' => 'x', + 'path' => '$.fields.x', + 'type' => 'string', + ], + 'y' => [ + 'name' => 'y', + 'path' => '$.fields.y', + 'type' => 'string', + ], + ], + ], + ] ); - $block_pattern = file_get_contents( __DIR__ . '/inc/patterns/map-pattern.html' ); - register_remote_data_block_pattern( $block_name, 'Elden Ring Map', $block_pattern, [ 'role' => 'inner_blocks' ] ); + $list_maps_query = HttpQuery::from_array( [ + 'data_source' => $elden_ring_data_source, + 'endpoint' => $elden_ring_data_source->get_endpoint() . '/tblS3OYo8tZOg04CP', + 'input_schema' => [ + 'search' => [ + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.records[*]', + 'type' => [ + 'id' => [ + 'name' => 'Map ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'map_name' => [ + 'name' => 'Name', + 'path' => '$.fields.Name', + 'type' => 'string', + ], + ], + ], + ] ); + + register_remote_data_block( [ + 'title' => $block_name, + 'queries' => [ + 'display' => $list_locations_query, + 'list' => $list_maps_query, + ], + 'patterns' => [ + [ + 'title' => 'Elden Ring Map', + 'html' => file_get_contents( __DIR__ . '/inc/patterns/map-pattern.html' ), + 'role' => 'inner_blocks', + ], + ], + ] ); $elden_ring_map_block_path = __DIR__ . '/build/blocks/elden-ring-map'; wp_register_style( 'leaflet-style', 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css', [], '1.9.4' ); diff --git a/example/airtable/events/inc/queries/class-airtable-get-event-query.php b/example/airtable/events/inc/queries/class-airtable-get-event-query.php deleted file mode 100644 index f4cd0357..00000000 --- a/example/airtable/events/inc/queries/class-airtable-get-event-query.php +++ /dev/null @@ -1,61 +0,0 @@ - [ - 'name' => 'Record ID', - 'overrides' => [ - [ - 'target' => 'utm_content', - 'type' => 'query_var', - ], - ], - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - /** - * Airtable API endpoint for fetching a single event. - */ - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/tblyGtuxblLtmoqMI/' . $input_variables['record_id']; - } - - public function get_query_name(): string { - return 'Get event'; - } -} diff --git a/example/airtable/events/inc/queries/class-airtable-list-events-query.php b/example/airtable/events/inc/queries/class-airtable-list-events-query.php deleted file mode 100644 index a0625c1b..00000000 --- a/example/airtable/events/inc/queries/class-airtable-list-events-query.php +++ /dev/null @@ -1,44 +0,0 @@ - '$.records[*]', - 'is_collection' => true, - 'mappings' => [ - 'record_id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - public function get_query_name(): string { - return 'List events'; - } - - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/tblyGtuxblLtmoqMI'; - } -} diff --git a/example/airtable/events/register.php b/example/airtable/events/register.php index 65c2b2ab..2dd10304 100644 --- a/example/airtable/events/register.php +++ b/example/airtable/events/register.php @@ -3,22 +3,39 @@ namespace RemoteDataBlocks\Example\Airtable\Events; use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; +use RemoteDataBlocks\Integrations\Airtable\AirtableIntegration; -require_once __DIR__ . '/inc/queries/class-airtable-get-event-query.php'; -require_once __DIR__ . '/inc/queries/class-airtable-list-events-query.php'; - -function register_airtable_events_block() { - $block_name = 'Conference Event'; +function register_airtable_events_block(): void { $access_token = \RemoteDataBlocks\Example\get_access_token( 'airtable_events' ); - $airtable_data_source = AirtableDataSource::create( $access_token, 'appVQ2PAl95wQSo9S', [], 'Conference Events' ); - $airtable_get_event_query = new AirtableGetEventQuery( $airtable_data_source ); - $airtable_list_events_query = new AirtableListEventsQuery( $airtable_data_source ); + if ( empty( $access_token ) ) { + return; + } + + $airtable_data_source = AirtableDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => $access_token, + 'base' => [ + 'id' => 'appVQ2PAl95wQSo9S', + 'name' => 'Conference Events', + ], + 'display_name' => 'Conference Events', + 'tables' => [], // AirtableDataSource does not formally provide queries. + ], + ] ); + + $block_options = [ + 'pages' => [ + [ + 'slug' => 'conference-event', + 'title' => 'Conference Events', + ], + ], + ]; - register_remote_data_block( $block_name, $airtable_get_event_query ); - register_remote_data_list_query( $block_name, $airtable_list_events_query ); - register_remote_data_loop_block( 'Conference Event List', $airtable_list_events_query ); - register_remote_data_page( $block_name, 'conference-event' ); + AirtableIntegration::register_block_for_airtable_data_source( $airtable_data_source, $block_options ); + AirtableIntegration::register_loop_block_for_airtable_data_source( $airtable_data_source, $block_options ); } add_action( 'init', __NAMESPACE__ . '\\register_airtable_events_block' ); diff --git a/example/github/remote-data-blocks/inc/queries/class-github-get-file-as-html-query.php b/example/github/remote-data-blocks/github-query-runner.php similarity index 58% rename from example/github/remote-data-blocks/inc/queries/class-github-get-file-as-html-query.php rename to example/github/remote-data-blocks/github-query-runner.php index 6e5ebe3c..e24a35ca 100644 --- a/example/github/remote-data-blocks/inc/queries/class-github-get-file-as-html-query.php +++ b/example/github/remote-data-blocks/github-query-runner.php @@ -2,96 +2,52 @@ namespace RemoteDataBlocks\Example\GitHub; -use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; -use RemoteDataBlocks\Integrations\GitHub\GitHubDataSource; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; +use RemoteDataBlocks\Config\QueryRunner\QueryRunner; +use DOMElement; +use DOMXPath; +use WP_Error; + +defined( 'ABSPATH' ) || exit(); + +/** + * Custom query runner that process custom processing for GitHub API responses + * that return HTML / Markdown instead of JSON. This also provides custom + * processing to adjust embedded links. + * + * Data fetching and caching is still delegated to the parent QueryRunner class. + */ +class GitHubQueryRunner extends QueryRunner { + private string $default_file_extension = '.md'; + + public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { + $input_variables['file_path'] = $this->ensure_file_extension( $input_variables['file_path'] ); + + return parent::execute( $query, $input_variables ); + } -class GitHubGetFileAsHtmlQuery extends HttpQueryContext { /** * @inheritDoc - * @param string|null $default_file_extension Optional file extension to append if missing (e.g., '.md') + * + * The API response is raw HTML, so we return an object construct containing + * the HTML as a property. */ - public function __construct( - private HttpDataSource $data_source, - private ?string $default_file_extension = null - ) { - parent::__construct( $data_source ); - } - - private function ensure_file_extension( string $file_path ): string { - if ( ! $this->default_file_extension ) { - return $file_path; - } - - return str_ends_with( $file_path, $this->default_file_extension ) ? $file_path : $file_path . $this->default_file_extension; - } - - public function get_input_schema(): array { + protected function deserialize_response( string $raw_response_data, array $input_variables ): array { return [ - 'file_path' => [ - 'name' => 'File Path', - 'type' => 'string', - 'overrides' => [ - [ - 'target' => 'utm_content', - 'type' => 'url', - ], - ], - 'transform' => function ( array $data ): string { - return $this->ensure_file_extension( $data['file_path'] ); - }, - ], + 'content' => $raw_response_data, + 'path' => $input_variables['file_path'], ]; } - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'file_content' => [ - 'name' => 'File Content', - 'path' => '$.content', - 'type' => 'html', - ], - 'file_path' => [ - 'name' => 'File Path', - 'path' => '$.path', - 'type' => 'string', - ], - ], - ]; - } - - public function get_endpoint( array $input_variables ): string { - /** @var GitHubDataSource $data_source */ - $data_source = $this->get_data_source(); - - return sprintf( - 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', - $data_source->get_repo_owner(), - $data_source->get_repo_name(), - $input_variables['file_path'], - $data_source->get_ref() - ); - } - - public function get_request_headers( array $input_variables ): array { - return [ - 'Accept' => 'application/vnd.github.html+json', - ]; + 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; } - public function process_response( string $html_response_data, array $input_variables ): array { - $content = $html_response_data; - $file_path = $input_variables['file_path']; - if ( '.md' === $this->default_file_extension ) { - $content = $this->update_markdown_links( $content, $file_path ); - } + public static function generate_file_content( array $response_data ): string { + $file_content = $response_data['content'] ?? ''; + $file_path = $response_data['file_path'] ?? ''; - return [ - 'content' => $content, - 'file_path' => $file_path, - ]; + return self::update_markdown_links( $file_content, $file_path ); } /** @@ -105,7 +61,7 @@ public function process_response( string $html_response_data, array $input_varia * @param string $current_file_path The current file's path. * @return string The updated HTML response data. */ - private function update_markdown_links( string $html, string $current_file_path = '' ): string { + private static function update_markdown_links( string $html, string $current_file_path = '' ): string { // Load the HTML into a DOMDocument $dom = new \DOMDocument(); @@ -117,12 +73,12 @@ private function update_markdown_links( string $html, string $current_file_path @$dom->loadHTML( $html, LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD ); // Create an XPath to query href attributes - $xpath = new \DOMXPath( $dom ); + $xpath = new DOMXPath( $dom ); // Query all elements with href attributes $nodes = $xpath->query( '//*[@href]' ); foreach ( $nodes as $node ) { - if ( ! $node instanceof \DOMElement ) { + if ( ! $node instanceof DOMElement ) { continue; } $href = $node->getAttribute( 'href' ); @@ -133,7 +89,7 @@ private function update_markdown_links( string $html, string $current_file_path ! preg_match( '/^(https?:)?\/\//', $href ) ) { // Adjust the path - $new_href = $this->adjust_markdown_file_path( $href, $current_file_path ); + $new_href = self::adjust_markdown_file_path( $href, $current_file_path ); // Set the new href $node->setAttribute( 'href', $new_href ); @@ -152,7 +108,7 @@ private function update_markdown_links( string $html, string $current_file_path * @param string $current_file_path The current file's path. * @return string The adjusted path. */ - private function adjust_markdown_file_path( string $path, string $current_file_path = '' ): string { + private static function adjust_markdown_file_path( string $path, string $current_file_path = '' ): string { global $post; $page_slug = $post->post_name; diff --git a/example/github/remote-data-blocks/inc/queries/class-github-list-files-query.php b/example/github/remote-data-blocks/inc/queries/class-github-list-files-query.php deleted file mode 100644 index fca38edb..00000000 --- a/example/github/remote-data-blocks/inc/queries/class-github-list-files-query.php +++ /dev/null @@ -1,54 +0,0 @@ - [ - 'name' => 'File Extension', - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => sprintf( '$.tree[?(@.path =~ /\\.%s$/)]', ltrim( $this->file_extension, '.' ) ), - 'is_collection' => true, - 'mappings' => [ - 'file_path' => [ - 'name' => 'File Path', - 'path' => '$.path', - 'type' => 'string', - ], - 'sha' => [ - 'name' => 'SHA', - 'path' => '$.sha', - 'type' => 'string', - ], - 'size' => [ - 'name' => 'Size', - 'path' => '$.size', - 'type' => 'number', - ], - 'url' => [ - 'name' => 'URL', - 'path' => '$.url', - 'type' => 'string', - ], - ], - ]; - } - - public function get_query_name(): string { - return 'List files'; - } -} diff --git a/example/github/remote-data-blocks/register.php b/example/github/remote-data-blocks/register.php index 3e390c46..33280070 100644 --- a/example/github/remote-data-blocks/register.php +++ b/example/github/remote-data-blocks/register.php @@ -2,34 +2,128 @@ namespace RemoteDataBlocks\Example\GitHub; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Integrations\GitHub\GitHubDataSource; -use RemoteDataBlocks\Logging\LoggerManager; -require_once __DIR__ . '/inc/queries/class-github-get-file-as-html-query.php'; -require_once __DIR__ . '/inc/queries/class-github-list-files-query.php'; +require_once __DIR__ . '/github-query-runner.php'; -function register_github_file_as_html_block() { - $repo_owner = 'Automattic'; - $repo_name = 'remote-data-blocks'; - $branch = 'trunk'; +function register_github_file_as_html_block(): void { + $service_config = [ + '__version' => 1, + 'display_name' => 'Automattic/remote-data-blocks#trunk', + 'ref' => 'trunk', + 'repo_owner' => 'Automattic', + 'repo_name' => 'remote-data-blocks', + ]; - $block_name = sprintf( 'GitHub File As HTML (%s/%s)', $repo_owner, $repo_name ); + $block_title = sprintf( 'GitHub File As HTML (%s/%s)', $service_config['repo_owner'], $service_config['repo_name'] ); + $file_extension = '.md'; + $github_data_source = GitHubDataSource::from_array( [ 'service_config' => $service_config ] ); - $github_data_source = GitHubDataSource::create( $repo_owner, $repo_name, $branch, '37fb2ac2-2399-4095-94d4-d58037d66c61' ); - $github_get_file_as_html_query = new GitHubGetFileAsHtmlQuery( $github_data_source, '.md' ); - $github_get_list_files_query = new GitHubListFilesQuery( $github_data_source, '.md' ); - - register_remote_data_block( $block_name, $github_get_file_as_html_query ); - register_remote_data_list_query( $block_name, $github_get_list_files_query ); + $github_get_file_as_html_query = HttpQuery::from_array( [ + 'data_source' => $github_data_source, + 'endpoint' => function ( array $input_variables ) use ( $service_config ): string { + return sprintf( + 'https://api.github.com/repos/%s/%s/contents/%s?ref=%s', + $service_config['repo_owner'], + $service_config['repo_name'], + $input_variables['file_path'], + $service_config['ref'] + ); + }, + 'input_schema' => [ + 'file_path' => [ + 'name' => 'File Path', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'file_content' => [ + 'name' => 'File Content', + 'generate' => [ GitHubQueryRunner::class, 'generate_file_content' ], + 'type' => 'html', + ], + 'file_path' => [ + 'name' => 'File Path', + 'path' => '$.path', + 'type' => 'string', + ], + ], + ], + 'request_headers' => [ + 'Accept' => 'application/vnd.github.html+json', + ], + 'query_runner' => new GitHubQueryRunner(), + ] ); - $block_pattern = file_get_contents( __DIR__ . '/inc/patterns/file-render.html' ); - register_remote_data_block_pattern( $block_name, 'GitHub File Render', $block_pattern, [ - 'role' => 'inner_blocks', + $github_get_list_files_query = HttpQuery::from_array( [ + 'data_source' => $github_data_source, + 'input_schema' => [ + 'file_extension' => [ + 'name' => 'File Extension', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => sprintf( '$.tree[?(@.path =~ /\\.%s$/)]', ltrim( $file_extension, '.' ) ), + 'type' => [ + 'file_path' => [ + 'name' => 'File Path', + 'path' => '$.path', + 'type' => 'string', + ], + 'sha' => [ + 'name' => 'SHA', + 'path' => '$.sha', + 'type' => 'string', + ], + 'size' => [ + 'name' => 'Size', + 'path' => '$.size', + 'type' => 'integer', + ], + 'url' => [ + 'name' => 'URL', + 'path' => '$.url', + 'type' => 'string', + ], + ], + ], ] ); - register_remote_data_page( $block_name, 'gh', [ 'allow_nested_paths' => true ] ); - $logger = LoggerManager::instance(); - $logger->info( sprintf( 'Registered %s block (branch: %s)', $block_name, $branch ) ); + register_remote_data_block( [ + 'title' => $block_title, + 'queries' => [ + 'display' => $github_get_file_as_html_query, + 'list' => $github_get_list_files_query, + ], + 'query_input_overrides' => [ + [ + 'query' => 'display', + 'source' => 'file_path', + 'source_type' => 'page', + 'target' => 'file_path', + 'target_type' => 'input_var', + ], + ], + 'pages' => [ + [ + 'allow_nested_paths' => true, + 'slug' => 'gh', + 'title' => 'GitHub File', + ], + ], + 'patterns' => [ + [ + 'html' => file_get_contents( __DIR__ . '/inc/patterns/file-render.html' ), + 'role' => 'inner_blocks', + 'title' => 'GitHub File Render', + ], + ], + ] ); } add_action( 'init', __NAMESPACE__ . '\\register_github_file_as_html_block' ); diff --git a/example/google-sheets/westeros-houses/inc/queries/class-get-westeros-houses-query.php b/example/google-sheets/westeros-houses/inc/queries/class-get-westeros-houses-query.php deleted file mode 100644 index 704c0f31..00000000 --- a/example/google-sheets/westeros-houses/inc/queries/class-get-westeros-houses-query.php +++ /dev/null @@ -1,89 +0,0 @@ - [ - 'name' => 'Row ID', - 'overrides' => [ - [ - 'target' => 'utm_content', - 'type' => 'query_var', - ], - ], - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'row_id' => [ - 'name' => 'Row ID', - 'path' => '$.RowId', - 'type' => 'id', - ], - 'house' => [ - 'name' => 'House', - 'path' => '$.House', - 'type' => 'string', - ], - 'seat' => [ - 'name' => 'Seat', - 'path' => '$.Seat', - 'type' => 'string', - ], - 'region' => [ - 'name' => 'Region', - 'path' => '$.Region', - 'type' => 'string', - ], - 'words' => [ - 'name' => 'Words', - 'path' => '$.Words', - 'type' => 'string', - ], - 'image_url' => [ - 'name' => 'Sigil', - 'path' => '$.Sigil', - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/values/Houses'; - } - - public function process_response( string $raw_response_data, array $input_variables ): string|array|object|null { - $parsed_response_data = json_decode( $raw_response_data, true ); - $selected_row = null; - $row_id = $input_variables['row_id']; - - if ( isset( $parsed_response_data['values'] ) && is_array( $parsed_response_data['values'] ) ) { - $raw_selected_row = $parsed_response_data['values'][ $row_id ]; - if ( is_array( $raw_selected_row ) ) { - $selected_row = array_combine( self::COLUMNS, $raw_selected_row ); - $selected_row = array_combine( self::COLUMNS, $selected_row ); - $selected_row['RowId'] = $row_id; - } - } - - return $selected_row; - } -} diff --git a/example/google-sheets/westeros-houses/inc/queries/class-list-westeros-houses-query.php b/example/google-sheets/westeros-houses/inc/queries/class-list-westeros-houses-query.php deleted file mode 100644 index aedd2dae..00000000 --- a/example/google-sheets/westeros-houses/inc/queries/class-list-westeros-houses-query.php +++ /dev/null @@ -1,79 +0,0 @@ - '$.values[*]', - 'is_collection' => true, - 'mappings' => [ - 'row_id' => [ - 'name' => 'Row ID', - 'path' => '$.RowId', - 'type' => 'id', - ], - 'house' => [ - 'name' => 'House', - 'path' => '$.House', - 'type' => 'string', - ], - 'seat' => [ - 'name' => 'Seat', - 'path' => '$.Seat', - 'type' => 'string', - ], - 'region' => [ - 'name' => 'Region', - 'path' => '$.Region', - 'type' => 'string', - ], - 'words' => [ - 'name' => 'Words', - 'path' => '$.Words', - 'type' => 'string', - ], - 'image_url' => [ - 'name' => 'Sigil', - 'path' => '$.Sigil', - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/values/Houses'; - } - - public function process_response( string $raw_response_data, array $input_variables ): string|array|object|null { - $parsed_response_data = json_decode( $raw_response_data, true ); - - if ( isset( $parsed_response_data['values'] ) && is_array( $parsed_response_data['values'] ) ) { - $values = $parsed_response_data['values']; - array_shift( $values ); // Drop the first row - - $parsed_response_data['values'] = array_map( - function ( $row, $index ) { - $combined = array_combine( self::COLUMNS, $row ); - $combined['RowId'] = $index + 1; // Add row_id field, starting from 1 - return $combined; - }, - $values, - array_keys( $values ) - ); - } - - return $parsed_response_data; - } -} diff --git a/example/google-sheets/westeros-houses/register.php b/example/google-sheets/westeros-houses/register.php index 0e55ea24..3647d477 100644 --- a/example/google-sheets/westeros-houses/register.php +++ b/example/google-sheets/westeros-houses/register.php @@ -2,36 +2,187 @@ namespace RemoteDataBlocks\Example\GoogleSheets\WesterosHouses; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Integrations\Google\Sheets\GoogleSheetsDataSource; -use RemoteDataBlocks\Logging\LoggerManager; -require_once __DIR__ . '/inc/queries/class-list-westeros-houses-query.php'; -require_once __DIR__ . '/inc/queries/class-get-westeros-houses-query.php'; - -function register_westeros_houses_block() { - $block_name = 'Westeros House'; +function register_westeros_houses_block(): void { $credentials = json_decode( base64_decode( \RemoteDataBlocks\Example\get_access_token( 'google_sheets_westeros_houses' ) ), true ); + $columns = [ + 'House', + 'Seat', + 'Region', + 'Words', + 'Sigil', + ]; if ( empty( $credentials ) ) { - $logger = LoggerManager::instance(); - $logger->warning( - sprintf( - '%s is not defined, cannot register %s block', - 'EXAMPLE_GOOGLE_SHEETS_WESTEROS_HOUSES_ACCESS_TOKEN', - $block_name - ) - ); return; } - $westeros_houses_data_source = GoogleSheetsDataSource::create( $credentials, '1EHdQg53Doz0B-ImrGz_hTleYeSvkVIk_NSJCOM1FQk0', 'Westeros Houses', ); - $list_westeros_houses_query = new ListWesterosHousesQuery( $westeros_houses_data_source ); - $get_westeros_houses_query = new GetWesterosHousesQuery( $westeros_houses_data_source ); + $westeros_houses_data_source = GoogleSheetsDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'credentials' => $credentials, + 'display_name' => 'Westeros Houses', + 'spreadsheet' => [ + 'id' => '1EHdQg53Doz0B-ImrGz_hTleYeSvkVIk_NSJCOM1FQk0', + ], + 'sheet' => [ + 'id' => 1, + 'name' => 'Houses', + ], + ], + ] ); + + $list_westeros_houses_query = HttpQuery::from_array( [ + 'data_source' => $westeros_houses_data_source, + 'endpoint' => $westeros_houses_data_source->get_endpoint() . '/values/Houses', + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.values[*]', + 'type' => [ + 'row_id' => [ + 'name' => 'Row ID', + 'path' => '$.RowId', + 'type' => 'id', + ], + 'house' => [ + 'name' => 'House', + 'path' => '$.House', + 'type' => 'string', + ], + 'seat' => [ + 'name' => 'Seat', + 'path' => '$.Seat', + 'type' => 'string', + ], + 'region' => [ + 'name' => 'Region', + 'path' => '$.Region', + 'type' => 'string', + ], + 'words' => [ + 'name' => 'Words', + 'path' => '$.Words', + 'type' => 'string', + ], + 'image_url' => [ + 'name' => 'Sigil', + 'path' => '$.Sigil', + 'type' => 'image_url', + ], + ], + ], + 'preprocess_response' => function ( mixed $response_data ) use ( $columns ): array { + if ( isset( $response_data['values'] ) && is_array( $response_data['values'] ) ) { + $values = $response_data['values']; + array_shift( $values ); // Drop the first row + + $response_data['values'] = array_map( + function ( $row, $index ) use ( $columns ) { + $combined = array_combine( $columns, $row ); + $combined['RowId'] = $index + 1; // Add row_id field, starting from 1 + return $combined; + }, + $values, + array_keys( $values ) + ); + } + + return $response_data; + }, + ] ); + + $get_westeros_houses_query = HttpQuery::from_array( [ + 'data_source' => $westeros_houses_data_source, + 'endpoint' => $westeros_houses_data_source->get_endpoint() . '/values/Houses', + 'input_schema' => [ + 'row_id' => [ + 'name' => 'Row ID', + 'type' => 'id', + ], + ], + 'output_schema' => [ + 'type' => [ + 'row_id' => [ + 'name' => 'Row ID', + 'path' => '$.RowId', + 'type' => 'id', + ], + 'house' => [ + 'name' => 'House', + 'path' => '$.House', + 'type' => 'string', + ], + 'seat' => [ + 'name' => 'Seat', + 'path' => '$.Seat', + 'type' => 'string', + ], + 'region' => [ + 'name' => 'Region', + 'path' => '$.Region', + 'type' => 'string', + ], + 'words' => [ + 'name' => 'Words', + 'path' => '$.Words', + 'type' => 'string', + ], + 'image_url' => [ + 'name' => 'Sigil', + 'path' => '$.Sigil', + 'type' => 'image_url', + ], + ], + ], + 'preprocess_response' => function ( mixed $response_data, array $input_variables ) use ( $columns ): array { + $selected_row = null; + $row_id = $input_variables['row_id']; + + if ( isset( $response_data['values'] ) && is_array( $response_data['values'] ) ) { + $raw_selected_row = $response_data['values'][ $row_id ]; + if ( is_array( $raw_selected_row ) ) { + $selected_row = array_combine( $columns, $raw_selected_row ); + $selected_row = array_combine( $columns, $selected_row ); + $selected_row['RowId'] = $row_id; + } + } + + return $selected_row; + }, + ] ); + + register_remote_data_block( [ + 'title' => 'Westeros House', + 'queries' => [ + 'display' => $get_westeros_houses_query, + 'list' => $list_westeros_houses_query, + ], + 'query_input_overrides' => [ + [ + 'query' => 'display', + 'source' => 'house', + 'source_type' => 'page', + 'target' => 'row_id', + 'target_type' => 'input_var', + ], + ], + 'pages' => [ + [ + 'slug' => 'westeros-houses', + 'title' => 'Westeros Houses', + ], + ], + ] ); - register_remote_data_block( $block_name, $get_westeros_houses_query ); - register_remote_data_list_query( $block_name, $list_westeros_houses_query ); - register_remote_data_loop_block( 'Westeros Houses List', $list_westeros_houses_query ); - register_remote_data_page( $block_name, 'westeros-houses' ); + register_remote_data_block( [ + 'title' => 'Westeros Houses List', + 'loop' => true, + 'queries' => [ + 'display' => $list_westeros_houses_query, + ], + ] ); } add_action( 'init', __NAMESPACE__ . '\\register_westeros_houses_block' ); diff --git a/example/rest-api/art-institute/inc/queries/class-art-institute-data-source.php b/example/rest-api/art-institute/inc/queries/class-art-institute-data-source.php deleted file mode 100644 index d8bdb6da..00000000 --- a/example/rest-api/art-institute/inc/queries/class-art-institute-data-source.php +++ /dev/null @@ -1,22 +0,0 @@ - 'application/json', - ]; - } -} diff --git a/example/rest-api/art-institute/inc/queries/class-art-institute-get-query.php b/example/rest-api/art-institute/inc/queries/class-art-institute-get-query.php deleted file mode 100644 index 9a68acd2..00000000 --- a/example/rest-api/art-institute/inc/queries/class-art-institute-get-query.php +++ /dev/null @@ -1,49 +0,0 @@ - [ - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'id' => [ - 'name' => 'ID', - 'path' => '$.data.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.data.title', - 'type' => 'string', - ], - 'image_id' => [ - 'name' => 'Image ID', - 'path' => '$.data.image_id', - 'type' => 'id', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'generate' => function ( $data ) { - return 'https://www.artic.edu/iiif/2/' . $data['data']['image_id'] . '/full/843,/0/default.jpg'; - }, - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . '/' . $input_variables['id']; - } -} diff --git a/example/rest-api/art-institute/inc/queries/class-art-institute-search-query.php b/example/rest-api/art-institute/inc/queries/class-art-institute-search-query.php deleted file mode 100644 index 989bc7b8..00000000 --- a/example/rest-api/art-institute/inc/queries/class-art-institute-search-query.php +++ /dev/null @@ -1,53 +0,0 @@ - [ - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => '$.data[*]', - 'is_collection' => true, - 'mappings' => [ - 'id' => [ - 'name' => 'Art ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.title', - 'type' => 'string', - ], - 'image' => [ - 'name' => 'Image', - 'path' => '$.thumbnail.lqip', - 'type' => 'image_url', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'generate' => function ( $data ) { - return 'https://www.artic.edu/iiif/2/' . $data['data']['image_id'] . '/full/843,/0/default.jpg'; - }, - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_endpoint( $input_variables ): string { - $query = $input_variables['search_terms']; - $endpoint = $this->get_data_source()->get_endpoint() . '/search'; - - return add_query_arg( [ 'q' => $query ], $endpoint ); - } -} diff --git a/example/rest-api/art-institute/register.php b/example/rest-api/art-institute/register.php index 716f5c4f..b6f312fa 100644 --- a/example/rest-api/art-institute/register.php +++ b/example/rest-api/art-institute/register.php @@ -2,20 +2,96 @@ namespace RemoteDataBlocks\Example\ArtInstituteOfChicago; -require_once __DIR__ . '/inc/queries/class-art-institute-data-source.php'; -require_once __DIR__ . '/inc/queries/class-art-institute-get-query.php'; -require_once __DIR__ . '/inc/queries/class-art-institute-search-query.php'; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Config\Query\HttpQuery; +use function add_query_arg; -function register_aic_block() { - $aic_data_source = ArtInstituteOfChicagoDataSource::from_array( [ - 'uuid' => '7c979e0d-67ca-44f8-a835-3c6fbf0f01d0', - 'service' => 'art-institute-of-chicago', +function register_aic_block(): void { + $aic_data_source = HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Art Institute of Chicago', + 'endpoint' => 'https://api.artic.edu/api/v1/artworks', + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], + ], ] ); - $get_art_query = new ArtInstituteOfChicagoGetArtQuery( $aic_data_source ); - $search_art_query = new ArtInstituteOfChicagoSearchArtQuery( $aic_data_source ); + $get_art_query = HttpQuery::from_array( [ + 'data_source' => $aic_data_source, + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + return sprintf( '%s/%s', $aic_data_source->get_endpoint(), $input_variables['id'] ?? '' ); + }, + 'input_schema' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'path' => '$.data', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'string', + ], + 'image_id' => [ + 'name' => 'Image ID', + 'type' => 'id', + ], + 'image_url' => [ + 'name' => 'Image URL', + 'generate' => function ( $data ): string { + return 'https://www.artic.edu/iiif/2/' . $data['image_id'] . '/full/843,/0/default.jpg'; + }, + 'type' => 'image_url', + ], + ], + ], + ] ); + + $search_art_query = HttpQuery::from_array( [ + 'data_source' => $aic_data_source, + 'endpoint' => function ( array $input_variables ) use ( $aic_data_source ): string { + $query = $input_variables['search_terms']; + $endpoint = $aic_data_source->get_endpoint() . '/search'; - register_remote_data_block( 'Art Institute of Chicago', $get_art_query ); - register_remote_data_search_query( 'Art Institute of Chicago', $search_art_query ); + return add_query_arg( [ 'q' => $query ], $endpoint ); + }, + 'input_schema' => [ + 'search_terms' => [ + 'name' => 'Search Terms', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.data[*]', + 'type' => [ + 'id' => [ + 'name' => 'Art ID', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'type' => 'string', + ], + ], + ], + ] ); + + register_remote_data_block( [ + 'title' => 'Art Institute of Chicago', + 'queries' => [ + 'display' => $get_art_query, + 'search' => $search_art_query, + ], + ] ); } add_action( 'init', __NAMESPACE__ . '\\register_aic_block' ); diff --git a/example/rest-api/zip-code/inc/queries/class-get-zip-code-query.php b/example/rest-api/zip-code/inc/queries/class-get-zip-code-query.php deleted file mode 100644 index fbddaf32..00000000 --- a/example/rest-api/zip-code/inc/queries/class-get-zip-code-query.php +++ /dev/null @@ -1,43 +0,0 @@ - [ - 'name' => 'Zip Code', - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - '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', - ], - ], - ]; - } - - public function get_endpoint( $input_variables ): string { - return $this->get_data_source()->get_endpoint() . $input_variables['zip_code']; - } -} diff --git a/example/rest-api/zip-code/register.php b/example/rest-api/zip-code/register.php index 3ed496d6..b0f62b97 100644 --- a/example/rest-api/zip-code/register.php +++ b/example/rest-api/zip-code/register.php @@ -2,21 +2,58 @@ namespace RemoteDataBlocks\Example\ZipCode; -use RemoteDataBlocks\Integrations\GenericHttp\GenericHttpDataSource; -use RemoteDataBlocks\Logging\LoggerManager; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Config\Query\HttpQuery; -require_once __DIR__ . '/inc/queries/class-get-zip-code-query.php'; +function register_zipcode_block(): void { + if ( ! defined( 'REMOTE_DATA_BLOCKS_EXAMPLE_ZIP_CODE_DATA_SOURCE_UUID' ) ) { + return; + } -function register_zipcode_block() { - $zipcode_data_source = GenericHttpDataSource::from_uuid( '0d8f9e74-5244-49b4-981b-e5374107aa5c' ); + $zipcode_data_source = HttpDataSource::from_uuid( REMOTE_DATA_BLOCKS_EXAMPLE_ZIP_CODE_DATA_SOURCE_UUID ); - if ( ! $zipcode_data_source instanceof GenericHttpDataSource ) { - LoggerManager::instance()->debug( 'Zip Code data source not found' ); + if ( ! $zipcode_data_source instanceof HttpDataSource ) { return; } - $zipcode_query = new GetZipCodeQuery( $zipcode_data_source ); + $zipcode_query = HttpQuery::from_array( [ + 'data_source' => $zipcode_data_source, + 'endpoint' => function ( array $input_variables ) use ( $zipcode_data_source ): string { + return $zipcode_data_source->get_endpoint() . $input_variables['zip_code']; + }, + 'input_schema' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'zip_code' => [ + 'name' => 'Zip Code', + 'path' => '$["post code"]', + 'type' => 'string', + ], + 'city' => [ + 'name' => 'City', + 'path' => '$.places[0]["place name"]', + 'type' => 'string', + ], + 'state' => [ + 'name' => 'State', + 'path' => '$.places[0].state', + 'type' => 'string', + ], + ], + ], + ] ); - register_remote_data_block( 'Zip Code', $zipcode_query ); + register_remote_data_block( [ + 'title' => 'Zip Code', + 'queries' => [ + 'display' => $zipcode_query, + ], + ] ); } add_action( 'init', __NAMESPACE__ . '\\register_zipcode_block' ); diff --git a/example/shopify/register.php b/example/shopify/register.php index 9075d539..887954b1 100644 --- a/example/shopify/register.php +++ b/example/shopify/register.php @@ -2,28 +2,26 @@ namespace RemoteDataBlocks\Example\Shopify; -use RemoteDataBlocks\Integrations\Shopify\Queries\ShopifyGetProductQuery; -use RemoteDataBlocks\Integrations\Shopify\Queries\ShopifySearchProductsQuery; use RemoteDataBlocks\Integrations\Shopify\ShopifyDataSource; -use RemoteDataBlocks\Logging\LoggerManager; +use RemoteDataBlocks\Integrations\Shopify\ShopifyIntegration; use function RemoteDataBlocks\Example\get_access_token; function register_shopify_block(): void { - $block_name = 'Shopify Example'; $access_token = get_access_token( 'shopify' ); - $store_name = 'stoph-test'; if ( empty( $access_token ) ) { - $logger = LoggerManager::instance(); - $logger->warning( sprintf( '%s is not defined, cannot register %s block', 'EXAMPLE_SHOPIFY_ACCESS_TOKEN', $block_name ) ); return; } - $shopify_data_source = ShopifyDataSource::create( $access_token, $store_name ); - $shopify_search_products_query = new ShopifySearchProductsQuery( $shopify_data_source ); - $shopify_get_product_query = new ShopifyGetProductQuery( $shopify_data_source ); + $shopify_data_source = ShopifyDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => $access_token, + 'display_name' => 'Shopify Example', + 'store_name' => 'stoph-test', + ], + ] ); - register_remote_data_block( $block_name, $shopify_get_product_query ); - register_remote_data_search_query( $block_name, $shopify_search_products_query ); + ShopifyIntegration::register_blocks_for_shopify_data_source( $shopify_data_source ); } add_action( 'init', __NAMESPACE__ . '\\register_shopify_block' ); diff --git a/functions.php b/functions.php index b4b43082..e8b4dd87 100644 --- a/functions.php +++ b/functions.php @@ -7,70 +7,13 @@ * interacting with Remote Data Blocks. */ -use RemoteDataBlocks\Config\QueryContext\QueryContextInterface; use RemoteDataBlocks\Editor\BlockManagement\ConfigRegistry; /** * Register a remote data block. * - * @param string $block_name The block name. - * @param QueryContextInterface $get_query The query used to fetch the remote data. + * @param array $block_config The block configuration. */ -function register_remote_data_block( string $block_name, QueryContextInterface $get_query ): void { - ConfigRegistry::register_block( $block_name, $get_query ); -} - -/** - * Register a remote data loop block, which displays a collection of remote data - * items. - * - * @param string $block_name The block name. - * @param QueryContextInterface $get_collection_query The query used to fetch the remote data collection. - */ -function register_remote_data_loop_block( string $block_name, QueryContextInterface $get_collection_query ): void { - ConfigRegistry::register_loop_block( $block_name, $get_collection_query ); -} - -/** - * Register a remote data list query to allow users to choose a remote data item - * from a list. - * - * @param string $block_name The block name. - * @param QueryContextInterface $get_collection_query The query used to fetch the remote data collection. - */ -function register_remote_data_list_query( string $block_name, QueryContextInterface $get_collection_query ): void { - ConfigRegistry::register_list_query( $block_name, $get_collection_query ); -} - -/** - * Register a remote data search query to allow users to search for a remote data - * item. - * - * @param string $block_name The block name. - * @param QueryContextInterface $search_collection_query The query used to search the remote data collection. - */ -function register_remote_data_search_query( string $block_name, QueryContextInterface $search_collection_query ): void { - ConfigRegistry::register_search_query( $block_name, $search_collection_query ); -} - -/** - * Register a block pattern that can used with a remote data block. - * - * @param string $block_name The block name. - * @param string $pattern_title The pattern title. - * @param string $pattern_html The pattern HTML. - * @param array $pattern_options The pattern options. - */ -function register_remote_data_block_pattern( string $block_name, string $pattern_title, string $pattern_html, array $pattern_options = [] ): void { - ConfigRegistry::register_block_pattern( $block_name, $pattern_title, $pattern_html, $pattern_options ); -} - -/** - * Register a remote data page. - * - * @param string $block_name The block name. - * @param string $slug The page slug. - */ -function register_remote_data_page( string $block_name, string $slug, array $options = [] ): void { - ConfigRegistry::register_page( $block_name, $slug, $options ); +function register_remote_data_block( array $block_config ): bool|WP_Error { + return ConfigRegistry::register_block( $block_config ); } diff --git a/inc/Config/ArraySerializable.php b/inc/Config/ArraySerializable.php new file mode 100644 index 00000000..e3373c5c --- /dev/null +++ b/inc/Config/ArraySerializable.php @@ -0,0 +1,55 @@ +config[ $property_name ] ?? null; + + if ( is_callable( $config_value ) ) { + return call_user_func_array( $config_value, $callable_args ); + } + + return $config_value; + } + + /** + * @inheritDoc + */ + public static function from_array( array $config, ?ValidatorInterface $validator = null ): self|WP_Error { + $schema = static::get_config_schema(); + + $validator = $validator ?? new Validator( $schema, static::class ); + $validated = $validator->validate( $config ); + + if ( is_wp_error( $validated ) ) { + return $validated; + } + + $sanitizer = new Sanitizer( $schema ); + $sanitized = $sanitizer->sanitize( $config ); + + return new static( $sanitized ); + } + + /** + * @inheritDoc + */ + public function to_array(): array { + return $this->config; + } + + abstract protected static function get_config_schema(): array; +} diff --git a/inc/Config/ArraySerializableInterface.php b/inc/Config/ArraySerializableInterface.php index d1279908..42fc2491 100644 --- a/inc/Config/ArraySerializableInterface.php +++ b/inc/Config/ArraySerializableInterface.php @@ -2,6 +2,8 @@ namespace RemoteDataBlocks\Config; +use RemoteDataBlocks\Validation\ValidatorInterface; + interface ArraySerializableInterface { /** * Creates an instance of the class from an array representation. @@ -10,11 +12,11 @@ interface ArraySerializableInterface { * using data provided in an array format. It's particularly useful for * deserialization or when creating objects from structured data (e.g., JSON). * - * @param array $data An associative array containing the configuration or - * data needed to create an instance of the class. + * @param array $config An associative array containing the configuration or data needed to create an instance of the class. + * @param ValidatorInterface|null $validator An optional validator instance to use for validating the configuration. * @return mixed Returns a new instance of the implementing class. */ - public static function from_array( array $data ): mixed; + public static function from_array( array $config, ?ValidatorInterface $validator ): mixed; /** * Converts the current object to an array representation. diff --git a/inc/Config/DataSource/DataSourceInterface.php b/inc/Config/DataSource/DataSourceInterface.php index 4c9eb2ad..0f50d577 100644 --- a/inc/Config/DataSource/DataSourceInterface.php +++ b/inc/Config/DataSource/DataSourceInterface.php @@ -2,6 +2,8 @@ namespace RemoteDataBlocks\Config\DataSource; +use RemoteDataBlocks\Config\ArraySerializableInterface; + /** * DataSourceInterface * @@ -12,26 +14,9 @@ * If you are a WPVIP customer, data sources are automatically provided by VIP. * Only implement this interface if you have additional custom data sources. */ -interface DataSourceInterface { - public const BASE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - '__metadata' => [ - 'type' => 'object', - 'required' => false, - ], - 'service' => [ 'type' => 'string' ], - 'service_schema_version' => [ 'type' => 'integer' ], - 'uuid' => [ - 'type' => 'string', - 'callback' => 'wp_is_uuid', - 'required' => false, - ], - ], - ]; - +interface DataSourceInterface extends ArraySerializableInterface { /** - * Get a human-readable name for this data source. + * Get a unique human-readable name for this data source. * * This method should return a display name for the data source that can be * used in user interfaces or for identification purposes. @@ -40,15 +25,6 @@ interface DataSourceInterface { */ public function get_display_name(): string; - /** - * Get the schema for the data source's configuration. - * - * This method should return an array that defines the schema for the data source's configuration. - * - * @return array The schema for the data source's configuration. - */ - public static function get_config_schema(): array; - /** * An optional image URL that can represent the data source in the block editor * (e.g., in modals or in the block inspector). @@ -56,4 +32,11 @@ public static function get_config_schema(): array; * @return string|null The image URL or null if not set. */ public function get_image_url(): ?string; + + /** + * Get a name for the underlying service for this data source + * + * @return string|null The service name of the data source. + */ + public function get_service_name(): ?string; } diff --git a/inc/Config/DataSource/HttpDataSource.php b/inc/Config/DataSource/HttpDataSource.php index 64d5dd14..9942a168 100644 --- a/inc/Config/DataSource/HttpDataSource.php +++ b/inc/Config/DataSource/HttpDataSource.php @@ -2,10 +2,8 @@ namespace RemoteDataBlocks\Config\DataSource; -use RemoteDataBlocks\Config\ArraySerializableInterface; -use RemoteDataBlocks\Config\UiDisplayableInterface; -use RemoteDataBlocks\Sanitization\Sanitizer; -use RemoteDataBlocks\Sanitization\SanitizerInterface; +use RemoteDataBlocks\Config\ArraySerializable; +use RemoteDataBlocks\Validation\ConfigSchemas; use RemoteDataBlocks\Validation\Validator; use RemoteDataBlocks\Validation\ValidatorInterface; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; @@ -16,52 +14,63 @@ * * Implements the HttpDataSourceInterface to define a generic HTTP data source. */ -abstract class HttpDataSource implements DataSourceInterface, HttpDataSourceInterface, ArraySerializableInterface, UiDisplayableInterface { - protected const SERVICE_NAME = 'unknown'; - protected const SERVICE_SCHEMA_VERSION = -1; - protected const SERVICE_SCHEMA = []; +class HttpDataSource extends ArraySerializable implements HttpDataSourceInterface { + protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE; + protected const SERVICE_SCHEMA_VERSION = 1; - final private function __construct( protected array $config ) {} - - abstract public function get_display_name(): string; - - abstract public function get_endpoint(): string; + final public function get_display_name(): string { + return $this->config['display_name']; + } - abstract public function get_request_headers(): array|WP_Error; + public function get_endpoint(): string { + return $this->config['endpoint']; + } - public function get_image_url(): ?string { - return null; + public function get_request_headers(): array|WP_Error { + return $this->get_or_call_from_config( 'request_headers' ) ?? []; } - /** - * Get the service name. - */ - public function get_service(): ?string { - return $this->config['service'] ?? null; + public function get_image_url(): ?string { + return $this->config['image_url'] ?? null; } - public function get_uuid(): string { - return $this->config['uuid']; + final public function get_service_name(): string { + return static::SERVICE_NAME; } /** * @inheritDoc + * + * NOTE: This method uses late static bindings to allow child classes to + * define their own validation schema. */ - final public static function get_config_schema(): array { - $schema = DataSourceInterface::BASE_SCHEMA; + public static function from_array( array $config, ?ValidatorInterface $validator = null ): self|WP_Error { + $service_config = $config['service_config'] ?? []; + $validator = $validator ?? new Validator( static::get_service_config_schema() ); + $validated = $validator->validate( $service_config ); - if ( isset( static::SERVICE_SCHEMA['properties'] ) ) { - $schema['properties'] = array_merge( DataSourceInterface::BASE_SCHEMA['properties'], static::SERVICE_SCHEMA['properties'] ); + if ( is_wp_error( $validated ) ) { + return $validated; } - return $schema; + return parent::from_array( + array_merge( + static::map_service_config( $service_config ), + [ + // Store the exact data used to create the instance to preserve determinism. + 'service' => static::SERVICE_NAME, + 'service_config' => $service_config, + 'uuid' => $config['uuid'] ?? null, + ] + ) + ); } public static function from_uuid( string $uuid ): DataSourceInterface|WP_Error { - $config = DataSourceCrud::get_by_uuid( $uuid ); + $config = DataSourceCrud::get_config_by_uuid( $uuid ); - if ( ! $config ) { - return new WP_Error( 'data_source_not_found', __( 'Data source not found.', 'remote-data-blocks' ), [ 'status' => 404 ] ); + if ( is_wp_error( $config ) ) { + return $config; } return static::from_array( $config ); @@ -69,41 +78,33 @@ public static function from_uuid( string $uuid ): DataSourceInterface|WP_Error { /** * @inheritDoc - * @psalm-suppress ParamNameMismatch reason: we want the clarity provided by the rename here + * + * TODO: Do we need to sanitize this to prevent leaking sensitive data? */ - final public static function from_array( array $config, ?ValidatorInterface $validator = null, ?SanitizerInterface $sanitizer = null ): DataSourceInterface|WP_Error { - $config['service_schema_version'] = static::SERVICE_SCHEMA_VERSION; - $schema = static::get_config_schema(); - - $validator = $validator ?? new Validator( $schema ); - $validated = $validator->validate( $config ); - - if ( is_wp_error( $validated ) ) { - return $validated; - } - - $sanitizer = $sanitizer ?? new Sanitizer( $schema ); - $sanitized = $sanitizer->sanitize( $config ); - - return new static( $sanitized ); + final public function to_array(): array { + return [ + 'service' => static::SERVICE_NAME, + 'service_config' => $this->config['service_config'], + 'uuid' => $this->config['uuid'], + ]; } /** * @inheritDoc */ - public function to_array(): array { - return $this->config; + protected static function get_config_schema(): array { + return ConfigSchemas::get_http_data_source_config_schema(); } - /** - * @inheritDoc - */ - public function to_ui_display(): array { - // TODO: Implement remove from children and implement here in standardized way + protected static function get_service_config_schema(): array { + return ConfigSchemas::get_http_data_source_service_config_schema(); + } + + protected static function map_service_config( array $service_config ): array { return [ - 'display_name' => $this->get_display_name(), - 'uuid' => $this->get_uuid(), - 'service' => static::SERVICE_NAME, + 'display_name' => $service_config['display_name'] ?? static::SERVICE_NAME, + 'endpoint' => $service_config['endpoint'] ?? null, // Invalid, but we won't guess it. + 'request_headers' => $service_config['request_headers'] ?? [], ]; } } diff --git a/inc/Config/DataSource/HttpDataSourceInterface.php b/inc/Config/DataSource/HttpDataSourceInterface.php index 0c126201..3dddb651 100644 --- a/inc/Config/DataSource/HttpDataSourceInterface.php +++ b/inc/Config/DataSource/HttpDataSourceInterface.php @@ -20,9 +20,9 @@ * * If you are a WPVIP customer, data sources are automatically provided by VIP. * Only implement this interface if you have custom data sources not provided by VIP. - * + * */ -interface HttpDataSourceInterface { +interface HttpDataSourceInterface extends DataSourceInterface { /** * Get the endpoint for the query. Note that the query configuration has an * opportunity to change / override the endpoint at request time. For REST diff --git a/inc/Config/Query/GraphqlMutation.php b/inc/Config/Query/GraphqlMutation.php new file mode 100644 index 00000000..530e2bd4 --- /dev/null +++ b/inc/Config/Query/GraphqlMutation.php @@ -0,0 +1,15 @@ +config['request_method'] ?? 'POST'; + } + + /** + * Convert the query and variables into a GraphQL request body. + */ + public function get_request_body( array $input_variables ): array { + return [ + 'query' => $this->config['graphql_query'], + 'variables' => empty( $input_variables ) ? [] : $input_variables, + ]; + } + + /** + * @inheritDoc + */ + protected static function get_config_schema(): array { + return ConfigSchemas::get_graphql_query_config_schema(); + } +} diff --git a/inc/Config/Query/HttpQuery.php b/inc/Config/Query/HttpQuery.php new file mode 100644 index 00000000..11cb5ea5 --- /dev/null +++ b/inc/Config/Query/HttpQuery.php @@ -0,0 +1,125 @@ +config['query_runner'] ?? new QueryRunner(); + + return $query_runner->execute( $this, $input_variables ); + } + + /** + * Override this method to define the cache object TTL for this query. Return + * -1 to disable caching. Return null to use the default cache TTL. + * + * @return int|null The cache object TTL in seconds. + */ + public function get_cache_ttl( array $input_variables ): null|int { + if ( isset( $this->config['cache_ttl'] ) ) { + return $this->get_or_call_from_config( 'cache_ttl', $input_variables ); + } + + // For most HTTP requests, we only want to cache GET requests. This is + // overridden for GraphQL queries when using GraphqlQuery + if ( 'GET' !== strtoupper( $this->get_request_method() ) ) { + // Disable caching. + return -1; + } + + // Use default cache TTL. + return null; + } + + /** + * Get the data source associated with this query. + */ + public function get_data_source(): HttpDataSourceInterface { + return $this->config['data_source']; + } + + /** + * Override this method to specify a custom endpoint for this query. + */ + public function get_endpoint( array $input_variables ): string { + return $this->get_or_call_from_config( 'endpoint', $input_variables ) ?? $this->get_data_source()->get_endpoint(); + } + + /** + * Override this method to specify a custom image URL for this query that will + * represent it in the UI. + */ + public function get_image_url(): string|null { + return $this->config['image_url'] ?? $this->get_data_source()->get_image_url(); + } + + public function get_input_schema(): array { + return $this->config['input_schema'] ?? []; + } + + public function get_output_schema(): array { + return $this->config['output_schema']; + } + + /** + * Override this method to define a request body for this query. A non-null + * result will be converted to JSON using `wp_json_encode`. + * + * @param array $input_variables The input variables for this query. + */ + public function get_request_body( array $input_variables ): ?array { + return $this->get_or_call_from_config( 'request_body', $input_variables ); + } + + /** + * Override this method to specify custom request headers for this query. + * + * @param array $input_variables The input variables for this query. + */ + public function get_request_headers( array $input_variables ): array|WP_Error { + return $this->get_or_call_from_config( 'request_headers', $input_variables ) ?? $this->get_data_source()->get_request_headers(); + } + + /** + * Override this method to define a request method for this query. + */ + public function get_request_method(): string { + return $this->config['request_method'] ?? 'GET'; + } + + /** + * @inheritDoc + */ + protected static function get_config_schema(): array { + return ConfigSchemas::get_http_query_config_schema(); + } + + /** + * Override this method to preprocess the response data before it is passed to + * the response parser. + * + * @param mixed $response_data The raw deserialized response data. + * @param array $input_variables The input variables for this query. + * @return mixed Preprocessed response data. + */ + public function preprocess_response( mixed $response_data, array $input_variables ): mixed { + return $this->get_or_call_from_config( 'preprocess_response', $response_data, $input_variables ) ?? $response_data; + } +} diff --git a/inc/Config/QueryContext/HttpQueryContextInterface.php b/inc/Config/Query/HttpQueryInterface.php similarity index 53% rename from inc/Config/QueryContext/HttpQueryContextInterface.php rename to inc/Config/Query/HttpQueryInterface.php index 0b13d770..4baa2cfd 100644 --- a/inc/Config/QueryContext/HttpQueryContextInterface.php +++ b/inc/Config/Query/HttpQueryInterface.php @@ -1,17 +1,21 @@ get_mutation_variables( $input_variables ); - - return [ - 'query' => $this->get_mutation(), - 'variables' => empty( $variables ) ? null : $variables, - ]; - } - - /** - * GraphQL mutations are uncachable by default. - */ - public function get_cache_ttl( array $input_variables ): int { - return -1; - } -} diff --git a/inc/Config/QueryContext/GraphqlQueryContext.php b/inc/Config/QueryContext/GraphqlQueryContext.php deleted file mode 100644 index 618ae7f1..00000000 --- a/inc/Config/QueryContext/GraphqlQueryContext.php +++ /dev/null @@ -1,61 +0,0 @@ -get_query_variables( $input_variables ); - - return [ - 'query' => $this->get_query(), - 'variables' => empty( $variables ) ? null : $variables, - ]; - } - - /** - * Override this method to define the cache object TTL for this query. Return - * -1 to disable caching. Return null to use the default cache TTL. - * - * @return int|null The cache object TTL in seconds. - */ - public function get_cache_ttl( array $input_variables ): ?int { - // Use default cache TTL. - return null; - } -} diff --git a/inc/Config/QueryContext/HttpQueryContext.php b/inc/Config/QueryContext/HttpQueryContext.php deleted file mode 100644 index d1d2b170..00000000 --- a/inc/Config/QueryContext/HttpQueryContext.php +++ /dev/null @@ -1,271 +0,0 @@ - 'object', - 'properties' => [ - 'input_schema' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'type' => [ 'type' => 'string' ], - 'name' => [ 'type' => 'string' ], - 'default_value' => [ - 'type' => 'string', - 'required' => false, - ], - 'overrides' => [ - 'type' => 'array', - 'required' => false, - ], - 'transform' => [ - 'type' => 'function', - 'required' => false, - ], - ], - ], - ], - 'output_schema' => [ - 'type' => 'object', - 'properties' => [ - 'root_path' => [ - 'type' => 'string', - 'required' => false, - ], - 'is_collection' => [ 'type' => 'boolean' ], - 'mappings' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - 'path' => [ - 'type' => 'string', - 'required' => false, - ], - 'generate' => [ - 'type' => 'function', - 'required' => false, - ], - 'type' => [ 'type' => 'string' ], - ], - ], - ], - ], - ], - 'query_name' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ]; - - /** - * Constructor. - * - * @param HttpDataSource $data_source The data source that this query will use. - * @param array $input_schema The input schema for this query. - * @param array $output_schema The output schema for this query. - */ - public function __construct( - private HttpDataSource $data_source, - public array $input_schema = [], - public array $output_schema = [], - protected array $config = [], - ) { - // Provide input and output variables as public properties. - $this->input_schema = $this->get_input_schema(); - $this->output_schema = $this->get_output_schema(); - - // @todo: expand or kill this - $this->config = $config; - } - - /** - * Override this method to define the input fields accepted by this query. The - * return value of this function will be passed to several methods in this - * class (e.g., `get_endpoint`, `get_request_body`). - * - * @return array { - * @type array $var_name { - * @type string $default_value Optional default value of the variable. - * @type string $name Display name of the variable. - * @type array $overrides { - * @type array { - * @type $target Targeted override. - * @type $type Override type. - * } - * } - * @type string $type The variable type (string, number, boolean) - * } - * } - * @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingTraversableTypeHintSpecification - */ - public function get_input_schema(): array { - return $this->input_schema; - } - - /** - * Override this method to define output fields produced by this query. - * - * @return array { - * @type array $var_name { - * @type string $default_value Optional default value of the variable. - * @type string $name Display name of the variable. - * @type string $path JSONPath expression to find the variable value. - * @type string $type The variable type (string, number, boolean) - * } - * } - * @phpcsSuppress SlevomatCodingStandard.TypeHints.PropertyTypeHint.MissingTraversableTypeHintSpecification - */ - public function get_output_schema(): array { - return $this->output_schema; - } - - /** - * Get the data source associated with this query. - */ - public function get_data_source(): HttpDataSource { - return $this->data_source; - } - - /** - * Override this method to specify a custom endpoint for this query. - */ - public function get_endpoint( array $input_variables ): string { - return $this->get_data_source()->get_endpoint(); - } - - /** - * Override this method to specify a custom image URL for this query that will - * represent it in the UI. - */ - public function get_image_url(): string|null { - return $this->get_data_source()->get_image_url(); - } - - /** - * Override this method to define a request method for this query. - */ - public function get_request_method(): string { - return 'GET'; - } - - /** - * Override this method to specify custom request headers for this query. - * - * @param array $input_variables The input variables for this query. - */ - public function get_request_headers( array $input_variables ): array|WP_Error { - return $this->get_data_source()->get_request_headers(); - } - - /** - * Override this method to define a request body for this query. The input - * variables are provided as a $key => $value associative array. - * - * The result will be converted to JSON using `wp_json_encode`. - * - * @param array $input_variables The input variables for this query. - * @return array|null - */ - public function get_request_body( array $input_variables ): ?array { - return null; - } - - /** - * Override this method to specify a name that represents this query in the - * block editor. - */ - public function get_query_name(): string { - return 'Query'; - } - - /** - * Override this method to specify a custom query runner for this query. - */ - public function get_query_runner(): QueryRunnerInterface { - return new QueryRunner( $this ); - } - - /** - * Override this method to define the cache object TTL for this query. Return - * -1 to disable caching. Return null to use the default cache TTL. - * - * @return int|null The cache object TTL in seconds. - */ - public function get_cache_ttl( array $input_variables ): null|int { - // For most HTTP requests, we only want to cache GET requests. This is - // overridden for GraphQL queries when using GraphqlQueryContext - if ( 'GET' !== strtoupper( $this->get_request_method() ) ) { - // Disable caching. - return -1; - } - - // Use default cache TTL. - return null; - } - - /** - * Override this method to process the raw response data from the query before - * it is passed to the query runner and the output variables are extracted. The - * result can be a JSON string, a PHP associative array, a PHP object, or null. - * - * @param string $raw_response_data The raw response data. - * @param array $input_variables The input variables for this query. - */ - public function process_response( string $raw_response_data, array $input_variables ): string|array|object|null { - return $raw_response_data; - } - - /** - * Authoritative truth of whether output is expected to be a collection. - */ - final public function is_response_data_collection(): bool { - return $this->output_schema['is_collection'] ?? false; - } - - // @todo: consider splitting the data source injection out from query context so we don't have to tie a query - // to a data source when instantiating. instead, we can just require applying queries to data sources in query - // runner execution. ie: $query_runner->execute( $query, $data_source ); - // - /** @psalm-suppress ParamNameMismatch reason: we want the clarity provided by the rename here */ - final public static function from_array( array $config, ?ValidatorInterface $validator = null ): static|\WP_Error { - if ( ! isset( $config['data_source'] ) || ! $config['data_source'] instanceof HttpDataSourceInterface ) { - return new \WP_Error( 'missing_data_source', __( 'Missing data source.', 'remote-data-blocks' ) ); - } - - $validator = $validator ?? new Validator( self::CONFIG_SCHEMA ); - $validated = $validator->validate( $config ); - - if ( is_wp_error( $validated ) ) { - return $validated; - } - - return new static( $config['data_source'], $config['input_schema'], $config['output_schema'], $config ); - } - - public function to_array(): array { - return $this->config; - } -} diff --git a/inc/Config/QueryContext/QueryContextInterface.php b/inc/Config/QueryContext/QueryContextInterface.php deleted file mode 100644 index 9abbc0e1..00000000 --- a/inc/Config/QueryContext/QueryContextInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - + */ + public function parse( mixed $data, array $schema ): mixed { + $json_obj = $data instanceof JsonObject ? $data : new JsonObject( $data ); + $value = $json_obj->get( $schema['path'] ?? '$' ); + + if ( is_array( $schema['type'] ?? null ) ) { + $value = $this->parse_response_objects( $value, $schema['type'] ) ?? []; + } elseif ( is_string( $schema['type'] ?? null ) ) { + $value = array_map( function ( $item ) use ( $schema ) { + return $this->get_field_value( $item, $schema['type'], $schema['default_value'] ?? null ); + }, $value ); + } else { + $value = []; + } + + $is_collection = $schema['is_collection'] ?? false; + return $is_collection ? $value : $value[0] ?? null; + } + + private function parse_response_objects( mixed $objects, array $type ): array { + if ( ! is_array( $objects ) ) { + return []; + } + + // Loop over the provided objects and parse it according to the provided schema type. + return array_map( function ( $object ) use ( $type ) { + $json_obj = new JsonObject( $object ); + $result = []; + + // Loop over the defined fields in the schema type and extract the values from the object. + foreach ( $type as $field_name => $mapping ) { + // A generate function accepts the current object and returns the field value. + if ( isset( $mapping['generate'] ) && is_callable( $mapping['generate'] ) ) { + $field_value = call_user_func( $mapping['generate'], json_decode( $json_obj->getJson(), true ) ); + } else { + $field_schema = array_merge( $mapping, [ 'path' => $mapping['path'] ?? "$.{$field_name}" ] ); + $field_value = $this->parse( $json_obj, $field_schema ); + } + + // A format function accepts the field value and formats it. + if ( isset( $mapping['format'] ) && is_callable( $mapping['format'] ) ) { + $field_value = call_user_func( $mapping['format'], $field_value ); + } + + $result[ $field_name ] = [ + 'name' => $mapping['name'] ?? $field_name, + // Convert complex types to string representation. + 'type' => is_string( $mapping['type'] ) ? $mapping['type'] : 'object', + 'value' => $field_value, + ]; + } + + // Nest result property to reserve additional meta in the future. + return [ + 'result' => $result, + ]; + }, $objects ); + } +} diff --git a/inc/Config/QueryRunner/QueryRunner.php b/inc/Config/QueryRunner/QueryRunner.php index 1e1229fe..2a8e5334 100644 --- a/inc/Config/QueryRunner/QueryRunner.php +++ b/inc/Config/QueryRunner/QueryRunner.php @@ -4,9 +4,7 @@ use Exception; use GuzzleHttp\RequestOptions; -use JsonPath\JsonObject; -use Parsedown; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\HttpClient\HttpClient; use WP_Error; @@ -15,16 +13,13 @@ /** * QueryRunner class * - * Class that executes queries, leveraging provided QueryContext. - * + * Class that executes queries. */ class QueryRunner implements QueryRunnerInterface { - - public function __construct( - private HttpQueryContext $query_context, - private HttpClient $http_client = new HttpClient() - ) { - } + /** + * @param HttpClient $http_client The HTTP client used to make HTTP requests. + */ + public function __construct( private HttpClient $http_client = new HttpClient() ) {} /** * Get the HTTP request details for the query @@ -36,20 +31,19 @@ public function __construct( * origin: string, * ttl: int|null, * uri: string, - * } The request details. + * } */ - protected function get_request_details( array $input_variables ): array|WP_Error { - $headers = $this->query_context->get_request_headers( $input_variables ); + protected function get_request_details( HttpQueryInterface $query, array $input_variables ): array|WP_Error { + $headers = $query->get_request_headers( $input_variables ); if ( is_wp_error( $headers ) ) { return $headers; } - $method = $this->query_context->get_request_method(); - $body = $this->query_context->get_request_body( $input_variables ); - $endpoint = $this->query_context->get_endpoint( $input_variables ); - $cache_ttl = $this->query_context->get_cache_ttl( $input_variables ); - + $method = $query->get_request_method(); + $body = $query->get_request_body( $input_variables ); + $endpoint = $query->get_endpoint( $input_variables ); + $cache_ttl = $query->get_cache_ttl( $input_variables ); $parsed_url = wp_parse_url( $endpoint ); if ( false === $parsed_url ) { @@ -60,10 +54,10 @@ protected function get_request_details( array $input_variables ): array|WP_Error * Filters the allowed URL schemes for this request. * * @param array $allowed_url_schemes The allowed URL schemes. - * @param HttpQueryContext $query_context The current query context. + * @param HttpQueryInterface $query The current query. * @return array The filtered allowed URL schemes. */ - $allowed_url_schemes = apply_filters( 'remote_data_blocks_allowed_url_schemes', [ 'https' ], $this->query_context ); + $allowed_url_schemes = apply_filters( 'remote_data_blocks_allowed_url_schemes', [ 'https' ], $query ); if ( empty( $parsed_url['scheme'] ?? '' ) || ! in_array( $parsed_url['scheme'], $allowed_url_schemes, true ) ) { return new WP_Error( 'Invalid endpoint URL scheme' ); @@ -98,7 +92,7 @@ protected function get_request_details( array $input_variables ): array|WP_Error * Filters the request details before the HTTP request is dispatched. * * @param array $request_details The request details. - * @param HttpQueryContext $query_context The query context. + * @param HttpQueryInterface $query The query being executed. * @param array $input_variables The input variables for the current request. * @return array */ - return apply_filters( 'remote_data_blocks_request_details', $request_details, $this->query_context, $input_variables ); + return apply_filters( 'remote_data_blocks_request_details', $request_details, $query, $input_variables ); } /** * Dispatch the HTTP request and assemble the raw (pre-processed) response data. * + * @param HttpQueryInterface $query The query being executed. * @param array $input_variables The input variables for the current request. * @return WP_Error|array{ * metadata: array, * response_data: string|array|object|null, * } */ - protected function get_raw_response_data( array $input_variables ): array|WP_Error { - $request_details = $this->get_request_details( $input_variables ); + protected function get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error { + $request_details = $this->get_request_details( $query, $input_variables ); if ( is_wp_error( $request_details ) ) { return $request_details; @@ -152,7 +147,7 @@ protected function get_raw_response_data( array $input_variables ): array|WP_Err 'age' => intval( $response->getHeaderLine( 'Age' ) ), 'status_code' => $response_code, ], - 'response_data' => $raw_response_string, + 'response_data' => $this->deserialize_response( $raw_response_string, $input_variables ), ]; } @@ -168,7 +163,7 @@ protected function get_raw_response_data( array $input_variables ): array|WP_Err * value: string|int|null, * }>, */ - protected function get_response_metadata( array $response_metadata, array $query_results ): array { + protected function get_response_metadata( HttpQueryInterface $query, array $response_metadata, array $query_results ): array { $age = intval( $response_metadata['age'] ?? 0 ); $time = time() - $age; @@ -180,7 +175,7 @@ protected function get_response_metadata( array $response_metadata, array $query ], 'total_count' => [ 'name' => 'Total count', - 'type' => 'number', + 'type' => 'integer', 'value' => count( $query_results ), ], ]; @@ -190,152 +185,67 @@ protected function get_response_metadata( array $response_metadata, array $query * field shortcodes. * * @param array $query_response_metadata The query response metadata. - * @param HttpQueryContext $query_context The query context. + * @param HttpQueryInterface $query The query context. * @param array $response_metadata The response metadata returned by the query runner. * @param array $query_results The results of the query. * @return array The filtered query response metadata. */ - return apply_filters( 'remote_data_blocks_query_response_metadata', $query_response_metadata, $this->query_context, $response_metadata, $query_results ); + return apply_filters( 'remote_data_blocks_query_response_metadata', $query_response_metadata, $query, $response_metadata, $query_results ); } /** * @inheritDoc */ - public function execute( array $input_variables ): array|WP_Error { - $raw_response_data = $this->get_raw_response_data( $input_variables ); + public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { + $raw_response_data = $this->get_raw_response_data( $query, $input_variables ); if ( is_wp_error( $raw_response_data ) ) { return $raw_response_data; } - // Loose validation of the raw response data. - if ( ! isset( $raw_response_data['metadata'], $raw_response_data['response_data'] ) || ! is_array( $raw_response_data['metadata'] ) ) { - return new WP_Error( 'Invalid raw response data' ); - } - - $metadata = $raw_response_data['metadata']; - $response_data = $raw_response_data['response_data']; - - // If the response data is a string, allow queries to implement their own - // deserialization logic. Otherwise, JsonPath is prepared to work with a - // string, array, object, or null. - if ( is_string( $response_data ) ) { - $response_data = $this->query_context->process_response( $response_data, $input_variables ); - } + // Preprocess the response data. + $response_data = $this->preprocess_response( $query, $raw_response_data['response_data'], $input_variables ); // Determine if the response data is expected to be a collection. - $is_collection = $this->query_context->is_response_data_collection(); + $schema = $query->get_output_schema(); + $is_collection = $schema['is_collection'] ?? false; - // This method always returns an array, even if it's a single item. This + // The parser always returns an array, even if it's a single item. This // ensures a consistent response shape. The requestor is expected to inspect // is_collection and unwrap if necessary. - $results = $this->map_fields( $response_data, $is_collection ); + $parser = new QueryResponseParser(); + $results = $parser->parse( $response_data, $schema ); + $results = $is_collection ? $results : [ $results ]; + $metadata = $this->get_response_metadata( $query, $raw_response_data['metadata'], $results ); return [ 'is_collection' => $is_collection, - 'metadata' => $this->get_response_metadata( $metadata, $results ), + 'metadata' => $metadata, 'results' => $results, ]; } /** - * Get the field value based on the field type. This method casts the field - * value to a string (since this will ultimately be used as block content). + * Deserialize the raw response data into an associative array. By default we + * assume a JSON string, but this method can be overridden to handle custom + * deserialization logic and/or transformation. + * + * @param string $raw_response_data The raw response data. + * @return mixed The deserialized response data. * - * @param array|string $field_value The field value. - * @param string $default_value The default value. - * @param string $field_type The field type. - * @return string The field value. + * @psalm-suppress PossiblyUnusedParam */ - protected function get_field_value( array|string $field_value, array $mapping ): string { - $default_value = $mapping['default_value'] ?? ''; - $field_type = $mapping['type']; - - $field_value_single = is_array( $field_value ) && count( $field_value ) > 1 ? $field_value : ( $field_value[0] ?? $default_value ); - - switch ( $field_type ) { - case 'base64': - return base64_decode( $field_value_single ); - - case 'html': - return $field_value_single; - - case 'markdown': - return Parsedown::instance()->text( $field_value_single ); - - case 'currency': - $currency_symbol = $mapping['prefix'] ?? '$'; - return sprintf( '%s%s', $currency_symbol, number_format( (float) $field_value_single, 2 ) ); - - case 'string': - if ( is_array( $field_value_single ) ) { - // Ensure all elements are strings and filter out non-string values - $string_values = array_filter( $field_value_single, '\is_string' ); - if ( ! empty( $string_values ) ) { - return wp_strip_all_tags( implode( ', ', $string_values ) ); - } - } - return wp_strip_all_tags( $field_value_single ); - } - - return (string) $field_value_single; + protected function deserialize_response( string $raw_response_data, array $input_variables ): mixed { + return json_decode( $raw_response_data, true ); } /** - * Map fields from the response data, adhering to the output schema defined by - * the query. + * Preprocess the response data before it is passed to the response parser. * - * @param string|array|object|null $response_data The response data to map. Can be JSON string, PHP associative array, PHP object, or null. - * @param bool $is_collection Whether the response data is a collection. - * @return null|array + * @param array $response_data The raw response data. + * @return array Preprocessed response. The deserialized response data or (re-)serialized JSON. */ - protected function map_fields( string|array|object|null $response_data, bool $is_collection ): ?array { - $root = $response_data; - $output_schema = $this->query_context->output_schema; - - if ( ! empty( $output_schema['root_path'] ) ) { - $json = new JsonObject( $root ); - $root = $json->get( $output_schema['root_path'] ); - } else { - $root = $is_collection ? $root : [ $root ]; - } - - if ( empty( $root ) || empty( $output_schema['mappings'] ) ) { - return $root; - } - - // Loop over the returned items in the query result. - return array_map( function ( $item ) use ( $output_schema ) { - $json = new JsonObject( $item ); - - // Loop over the output variables and extract the values from the item. - $result = array_map( function ( $mapping ) use ( $json ) { - if ( array_key_exists( 'generate', $mapping ) && is_callable( $mapping['generate'] ) ) { - $field_value_single = $mapping['generate']( json_decode( $json->getJson(), true ) ); - } else { - $field_path = $mapping['path'] ?? null; - $field_value = $field_path ? $json->get( $field_path ) : ''; - - // JSONPath always returns values in an array, even if there's only one value. - // Because we're mostly interested in single values for field mapping, unwrap the array if it's only one item. - $field_value_single = self::get_field_value( $field_value, $mapping ); - } - - return array_merge( $mapping, [ - 'value' => $field_value_single, - ] ); - }, $output_schema['mappings'] ); - - // Nest result property to reserve additional meta in the future. - return [ - 'result' => $result, - ]; - }, $root ); + protected function preprocess_response( HttpQueryInterface $query, mixed $response_data, array $input_variables ): mixed { + return $query->preprocess_response( $response_data, $input_variables ); } } diff --git a/inc/Config/QueryRunner/QueryRunnerInterface.php b/inc/Config/QueryRunner/QueryRunnerInterface.php index aa4865ae..4319e24c 100644 --- a/inc/Config/QueryRunner/QueryRunnerInterface.php +++ b/inc/Config/QueryRunner/QueryRunnerInterface.php @@ -2,12 +2,14 @@ namespace RemoteDataBlocks\Config\QueryRunner; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; use WP_Error; interface QueryRunnerInterface { /** * Execute the query and return processed results. * + * @param HttpQueryInterface $query The query to execute. * @param array $input_variables The input variables for the current request. * @return WP_Error|array{ * is_collection: bool, @@ -25,5 +27,5 @@ interface QueryRunnerInterface { * }>, * } */ - public function execute( array $input_variables ): array|\WP_Error; + public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error; } diff --git a/inc/Config/UiDisplayableInterface.php b/inc/Config/UiDisplayableInterface.php deleted file mode 100644 index b12f51fb..00000000 --- a/inc/Config/UiDisplayableInterface.php +++ /dev/null @@ -1,12 +0,0 @@ -input_schema, function ( $input_var ) { - return isset( $input_var['overrides'] ); - } ); $formatted_overrides = []; - foreach ( $input_vars_with_overrides as $name => $input_var ) { - $formatted_overrides[ $name ] = array_merge( $input_var, [ - 'overrides' => array_map( function ( $override ) use ( $name ) { - $display = ''; - switch ( $override['type'] ) { - case 'query_var': - $display = sprintf( '?%s={%s}', $override['target'], $name ); - break; - case 'url': - $display = sprintf( '/%s/{%s}', $override['target'], $name ); - break; - } - - $override['display'] = $override['display'] ?? $display; - - return $override; - }, $input_var['overrides'] ), - ] ); + foreach ( $config['query_input_overrides'] as $override ) { + $formatted_overrides[ $override['target'] ] = [ + [ + 'display' => sprintf( '%s={%s}', $override['source'], $override['target'] ), + 'source' => $override['source'], + 'sourceType' => $override['source_type'], + ], + ]; } // Set available bindings from the display query output mappings. $available_bindings = []; - foreach ( $config['queries']['__DISPLAY__']->output_schema['mappings'] ?? [] as $key => $mapping ) { + $output_schema = $config['queries'][ ConfigRegistry::DISPLAY_QUERY_KEY ]->get_output_schema(); + foreach ( $output_schema['type'] ?? [] as $key => $mapping ) { $available_bindings[ $key ] = [ 'name' => $mapping['name'], 'type' => $mapping['type'], @@ -104,7 +91,7 @@ public static function register_blocks(): void { $scripts_to_localize[] = $block_type->editor_script_handles[0]; // Register a default pattern that simply displays the available data. - $default_pattern_name = BlockPatterns::register_default_block_pattern( $block_name, $config['title'], $config['queries']['__DISPLAY__'] ); + $default_pattern_name = BlockPatterns::register_default_block_pattern( $block_name, $config['title'], $config['queries'][ ConfigRegistry::DISPLAY_QUERY_KEY ] ); $remote_data_blocks_config[ $block_name ]['patterns']['default'] = $default_pattern_name; } @@ -112,7 +99,7 @@ public static function register_blocks(): void { wp_localize_script( $script_handle, 'REMOTE_DATA_BLOCKS', [ 'config' => $remote_data_blocks_config, 'rest_url' => RemoteDataController::get_url(), - 'tracks_global_properties' => TracksAnalytics::get_global_properties(), + 'tracks_global_properties' => TracksAnalytics::get_global_properties(), ] ); } } diff --git a/inc/Editor/BlockManagement/ConfigRegistry.php b/inc/Editor/BlockManagement/ConfigRegistry.php index abd618b8..995726a0 100644 --- a/inc/Editor/BlockManagement/ConfigRegistry.php +++ b/inc/Editor/BlockManagement/ConfigRegistry.php @@ -4,10 +4,12 @@ defined( 'ABSPATH' ) || exit(); -use RemoteDataBlocks\Config\QueryContext\QueryContextInterface; use RemoteDataBlocks\Logging\LoggerManager; use Psr\Log\LoggerInterface; use RemoteDataBlocks\Editor\BlockPatterns\BlockPatterns; +use RemoteDataBlocks\Validation\ConfigSchemas; +use RemoteDataBlocks\Validation\Validator; +use WP_Error; use function get_page_by_path; use function parse_blocks; @@ -18,28 +20,47 @@ class ConfigRegistry { private static LoggerInterface $logger; + public const DISPLAY_QUERY_KEY = 'display'; + public const LIST_QUERY_KEY = 'list'; + public const SEARCH_QUERY_KEY = 'search'; + public static function init( ?LoggerInterface $logger = null ): void { self::$logger = $logger ?? LoggerManager::instance(); ConfigStore::init( self::$logger ); } - public static function register_block( string $block_title, QueryContextInterface $display_query, array $options = [] ): void { + public static function register_block( array $user_config = [] ): bool|WP_Error { + // Validate the provided user configuration. + $schema = ConfigSchemas::get_remote_data_block_config_schema(); + $validator = new Validator( $schema, static::class ); + $validated = $validator->validate( $user_config ); + + if ( is_wp_error( $validated ) ) { + return $validated; + } + + // Check if the block has already been registered. + $block_title = $user_config['title']; $block_name = ConfigStore::get_block_name( $block_title ); if ( ConfigStore::is_registered_block( $block_name ) ) { - self::$logger->error( sprintf( 'Block %s has already been registered', $block_name ) ); - return; + return self::create_error( $block_title, sprintf( 'Block %s has already been registered', $block_name ) ); } - // This becomes our target shape for a static config approach, but let's - // give it some time to solidify. + $display_query = $user_config['queries'][ self::DISPLAY_QUERY_KEY ]; + $input_schema = $display_query->get_input_schema(); + + // Build the base configuration for the block. This is our own internal + // configuration, not what will be passed to WordPress's register_block_type. + // @see BlockRegistration::register_block_type::register_blocks. $config = [ 'description' => '', 'name' => $block_name, - 'loop' => $options['loop'] ?? false, + 'loop' => $user_config['loop'] ?? false, 'patterns' => [], 'queries' => [ - '__DISPLAY__' => $display_query, + self::DISPLAY_QUERY_KEY => $display_query, ], + 'query_input_overrides' => [], 'selectors' => [ [ 'image_url' => $display_query->get_image_url(), @@ -50,85 +71,141 @@ public static function register_block( string $block_title, QueryContextInterfac 'slug' => $slug, 'type' => $input_var['type'] ?? 'string', ]; - }, array_keys( $display_query->input_schema ), array_values( $display_query->input_schema ) ), + }, array_keys( $input_schema ), array_values( $input_schema ) ), 'name' => 'Manual input', - 'query_key' => '__DISPLAY__', + 'query_key' => self::DISPLAY_QUERY_KEY, 'type' => 'input', ], ], 'title' => $block_title, ]; - ConfigStore::set_configuration( $block_name, $config ); - } + // Register "selectors" which allow the user to use a query to assist in + // selecting data for display by the block. + foreach ( [ self::LIST_QUERY_KEY, self::SEARCH_QUERY_KEY ] as $from_query_type ) { + if ( isset( $user_config['queries'][ $from_query_type ] ) ) { + $to_query = $display_query; + $from_query = $user_config['queries'][ $from_query_type ]; + + $config['queries'][ $from_query_type ] = $from_query; + + $from_input_schema = $from_query->get_input_schema(); + $from_output_schema = $from_query->get_output_schema(); + + foreach ( array_keys( $to_query->get_input_schema() ) as $to ) { + if ( ! isset( $from_output_schema['type'][ $to ] ) ) { + return self::create_error( $block_title, sprintf( 'Cannot map key "%s" from %s query', esc_html( $to ), $from_query_type ) ); + } + } + + if ( self::SEARCH_QUERY_KEY === $from_query_type && ! isset( $from_input_schema['search_terms'] ) ) { + return self::create_error( $block_title, 'A search query must have a "search_terms" input variable' ); + } + + // Add the selector to the configuration. + array_unshift( + $config['selectors'], + [ + 'image_url' => $from_query->get_image_url(), + 'inputs' => [], + 'name' => ucfirst( $from_query_type ), + 'query_key' => $from_query_type, + 'type' => $from_query_type, + ] + ); + } + } - public static function register_loop_block( string $block_title, QueryContextInterface $display_query ): void { - self::register_block( $block_title, $display_query, [ 'loop' => true ] ); - } + // Register query input overrides which allow the user to specify how + // query inputs can be overridden by URL parameters or query variables. + foreach ( $user_config['query_input_overrides'] ?? [] as $override ) { + if ( ! isset( $config['queries'][ $override['query'] ] ) ) { + return self::create_error( $block_title, sprintf( 'Query input override targets a non-existent query "%s"', esc_html( $override['query'] ) ) ); + } - public static function register_block_pattern( string $block_title, string $pattern_title, string $pattern_content, array $pattern_options = [] ): void { - $block_name = ConfigStore::get_block_name( $block_title ); - $config = ConfigStore::get_configuration( $block_name ); + if ( 'input_var' !== $override['target_type'] ) { + return self::create_error( $block_title, 'Only input variables can be targeted by query input overrides' ); + } - if ( null === $config ) { - return; + if ( ! isset( $config['queries'][ $override['query'] ]->get_input_schema()[ $override['target'] ] ) ) { + return self::create_error( $block_title, sprintf( 'Query input override "%s" does not exist as input variable for query "%s"', esc_html( $override['target'] ), esc_html( $override['query'] ) ) ); + } + + $config['query_input_overrides'][] = $override; } + // Register patterns which can be used with the block. + foreach ( $user_config['patterns'] ?? [] as $pattern ) { + $parsed_blocks = parse_blocks( $pattern['html'] ); + $parsed_blocks = BlockPatterns::add_block_arg_to_bindings( $block_name, $parsed_blocks ); + $pattern_content = serialize_blocks( $parsed_blocks ); + + $pattern_name = self::register_block_pattern( $block_name, $pattern['title'], $pattern_content ); + + // If the pattern role is specified and recognized, add it to the block configuration. + $recognized_roles = [ 'inner_blocks' ]; + if ( isset( $pattern['role'] ) && in_array( $pattern['role'], $recognized_roles, true ) ) { + $config['patterns'][ $pattern['role'] ] = $pattern_name; + } + } + + // Register pages assosciated with the block. + foreach ( $user_config['pages'] ?? [] as $page_options ) { + $registered = self::register_page( $config['query_input_overrides'], $block_title, $page_options ); + + if ( is_wp_error( $registered ) ) { + return self::create_error( $block_title, $registered->get_error_message() ); + } + } + + ConfigStore::set_block_configuration( $block_name, $config ); + + return true; + } + + private static function register_block_pattern( string $block_name, string $pattern_title, string $pattern_content ): string { // Add the block arg to any bindings present in the pattern. - $parsed_blocks = parse_blocks( $pattern_content ); - $parsed_blocks = BlockPatterns::add_block_arg_to_bindings( $block_name, $parsed_blocks ); - $pattern_content = serialize_blocks( $parsed_blocks ); - $pattern_name = 'remote-data-blocks/' . sanitize_title( $pattern_title ); + $pattern_name = 'remote-data-blocks/' . sanitize_title_with_dashes( $pattern_title ); // Create the pattern properties, allowing overrides via pattern options. - $pattern_properties = array_merge( - [ - 'blockTypes' => [ $block_name ], - 'categories' => [ 'Remote Data' ], - 'content' => $pattern_content, - 'inserter' => true, - 'source' => 'plugin', - 'title' => $pattern_title, - ], - $pattern_options['properties'] ?? [] - ); + $pattern_properties = [ + 'blockTypes' => [ $block_name ], + 'categories' => [ 'Remote Data' ], + 'content' => $pattern_content, + 'inserter' => true, + 'source' => 'plugin', + 'title' => $pattern_title, + ]; // Register the pattern. register_block_pattern( $pattern_name, $pattern_properties ); - // If the pattern role is specified and recognized, add it to the block configuration. - $recognized_roles = [ 'inner_blocks' ]; - if ( isset( $pattern_options['role'] ) && in_array( $pattern_options['role'], $recognized_roles, true ) ) { - $config['patterns'][ $pattern_options['role'] ] = $pattern_name; - ConfigStore::set_configuration( $block_name, $config ); - } + return $pattern_name; } /** - * Registers a page query with optional configuration. + * Registers a page with optional configuration. * - * @param string $block_title The block title. - * @param string $page_slug The page slug. + * @param array $query_input_overrides The query input overrides that will be targeted on the page. + * @param string $block_title The title of the block associated with the page. * @param array { - * allow_nested_paths?: bool - * } $options Optional. Configuration options for the rewrite rule. + * allow_nested_paths?: bool + * slug: string + * title?: string + * } $options Configuration options for the page and rewrite rule. */ - public static function register_page( string $block_title, string $page_slug, array $options = [] ): void { - - $block_name = ConfigStore::get_block_name( $block_title ); - $config = ConfigStore::get_configuration( $block_name ); - $allow_nested_paths = $options['allow_nested_paths'] ?? false; + private static function register_page( array $query_input_overrides, string $block_title, array $options = [] ): bool|WP_Error { + $overrides = array_values( array_filter( $query_input_overrides, function ( $override ) { + return 'page' === $override['source_type'] && ConfigRegistry::DISPLAY_QUERY_KEY === $override['query']; + } ) ); - if ( null === $config ) { - return; + if ( empty( $overrides ) ) { + return new WP_Error( 'useless_page', 'A page is only useful with query input overrides with page sources.' ); } - $display_query = $config['queries']['__DISPLAY__']; - - if ( empty( $display_query->input_schema ?? [] ) ) { - self::$logger->error( 'A page is only useful for queries with input variables.' ); - return; - } + $allow_nested_paths = $options['allow_nested_paths'] ?? false; + $page_slug = $options['slug']; + $page_title = $options['title'] ?? $block_title; // Create the page if it doesn't already exist. if ( null === get_page_by_path( '/' . $page_slug ) ) { @@ -141,109 +218,37 @@ public static function register_page( string $block_title, string $page_slug, ar 'post_content' => $post_content, 'post_name' => $page_slug, 'post_status' => 'draft', - 'post_title' => $block_title, + 'post_title' => $page_title, 'post_type' => 'page', ] ); } // Add a rewrite rule targeting the provided page slug. - $query_vars = array_keys( $display_query->input_schema ); - $query_var_pattern = '/([^/]+)'; /** * If nested paths are allowed and there is only one query variable, * allow slashes in the query variable value. */ - if ( $allow_nested_paths && 1 === count( $query_vars ) ) { + if ( $allow_nested_paths && 1 === count( $overrides ) ) { $query_var_pattern = '/(.+)'; } - - $rewrite_rule = sprintf( '^%s%s/?$', $page_slug, str_repeat( $query_var_pattern, count( $query_vars ) ) ); - $rewrite_rule_target = sprintf( 'index.php?pagename=%s', $page_slug ); - foreach ( $query_vars as $index => $query_var ) { - $rewrite_rule_target .= sprintf( '&%s=$matches[%d]', $query_var, $index + 1 ); - - if ( ! isset( $display_query->input_schema[ $query_var ]['overrides'] ) ) { - $display_query->input_schema[ $query_var ]['overrides'] = []; - } + $rewrite_rule = sprintf( '^%s%s/?$', $page_slug, str_repeat( $query_var_pattern, count( $overrides ) ) ); + $rewrite_rule_target = sprintf( 'index.php?pagename=%s', $page_slug ); - // Add the URL variable override to the display query. - $display_query->input_schema[ $query_var ]['overrides'][] = [ - 'target' => $page_slug, - 'type' => 'url', - ]; + foreach ( $overrides as $index => $override ) { + $rewrite_rule_target .= sprintf( '&%s=$matches[%d]', $override['source'], $index + 1 ); } add_rewrite_rule( $rewrite_rule, $rewrite_rule_target, 'top' ); - } - - private static function register_selector( string $block_title, string $type, QueryContextInterface $query ): void { - $block_name = ConfigStore::get_block_name( $block_title ); - $config = ConfigStore::get_configuration( $block_name ); - $query_key = $query::class; - if ( null === $config ) { - return; - } - - // Verify mappings. - $to_query = $config['queries']['__DISPLAY__']; - foreach ( array_keys( $to_query->input_schema ) as $to ) { - if ( ! isset( $query->output_schema['mappings'][ $to ] ) ) { - self::$logger->error( sprintf( 'Cannot map key "%s" from query "%s"', esc_html( $to ), $query_key ) ); - return; - } - } - - self::register_query( $block_title, $query ); - - // Add the selector to the configuration. Fetch config again since it was - // updated in register_query. - $config = ConfigStore::get_configuration( $block_name ); - array_unshift( - $config['selectors'], - [ - 'image_url' => $query->get_image_url(), - 'inputs' => [], - 'name' => $query->get_query_name(), - 'query_key' => $query_key, - 'type' => $type, - ] - ); - - ConfigStore::set_configuration( $block_name, $config ); - } - - public static function register_query( string $block_title, QueryContextInterface $query ): void { - $block_name = ConfigStore::get_block_name( $block_title ); - $config = ConfigStore::get_configuration( $block_name ); - $query_key = $query::class; - - if ( null === $config ) { - return; - } - - if ( isset( $config['queries'][ $query_key ] ) ) { - self::$logger->error( sprintf( 'Query %s has already been registered', $query_key ) ); - return; - } - - $config['queries'][ $query_key ] = $query; - ConfigStore::set_configuration( $block_name, $config ); + return true; } - public static function register_list_query( string $block_title, QueryContextInterface $query ): void { - self::register_selector( $block_title, 'list', $query ); - } - - public static function register_search_query( string $block_title, QueryContextInterface $query ): void { - if ( ! isset( $query->input_schema['search_terms'] ) ) { - self::$logger->error( sprintf( 'A search query must have a "search_terms" input variable: %s', $query::class ) ); - return; - } - - self::register_selector( $block_title, 'search', $query ); + private static function create_error( string $block_title, string $message ): WP_Error { + $error_message = sprintf( 'Error registering block %s: %s', esc_html( $block_title ), esc_html( $message ) ); + self::$logger->error( $error_message ); + return new WP_Error( 'block_registration_error', $error_message ); } } diff --git a/inc/Editor/BlockManagement/ConfigStore.php b/inc/Editor/BlockManagement/ConfigStore.php index 34238035..24d4cf20 100644 --- a/inc/Editor/BlockManagement/ConfigStore.php +++ b/inc/Editor/BlockManagement/ConfigStore.php @@ -4,22 +4,22 @@ defined( 'ABSPATH' ) || exit(); +use RemoteDataBlocks\Config\Query\QueryInterface; use RemoteDataBlocks\Logging\LoggerManager; use Psr\Log\LoggerInterface; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; -use RemoteDataBlocks\Config\UiDisplayableInterface; -use function sanitize_title; +use function sanitize_title_with_dashes; class ConfigStore { /** * @var array> */ - private static array $configurations; + private static array $blocks = []; + private static LoggerInterface $logger; public static function init( ?LoggerInterface $logger = null ): void { - self::$configurations = []; + self::$blocks = []; self::$logger = $logger ?? LoggerManager::instance(); } @@ -29,43 +29,41 @@ public static function init( ?LoggerInterface $logger = null ): void { * titles must be unique). */ public static function get_block_name( string $block_title ): string { - return 'remote-data-blocks/' . sanitize_title( $block_title ); + return 'remote-data-blocks/' . sanitize_title_with_dashes( $block_title ); } /** - * Get all registered block names. - * - * @return string[] + * Get the configuration for a block. */ - public static function get_block_names(): array { - return array_keys( self::$configurations ); + public static function get_block_configurations(): array { + return self::$blocks; } /** * Get the configuration for a block. */ - public static function get_configuration( string $block_name ): ?array { + public static function get_block_configuration( string $block_name ): ?array { if ( ! self::is_registered_block( $block_name ) ) { self::$logger->error( sprintf( 'Block %s has not been registered', $block_name ) ); return null; } - return self::$configurations[ $block_name ]; + return self::$blocks[ $block_name ]; } /** * Set or update the configuration for a block. */ - public static function set_configuration( string $block_name, array $config ): void { + public static function set_block_configuration( string $block_name, array $config ): void { // @TODO: Validate config shape. - self::$configurations[ $block_name ] = $config; + self::$blocks[ $block_name ] = $config; } /** * Check if a block is registered. */ public static function is_registered_block( string $block_name ): bool { - return isset( self::$configurations[ $block_name ] ); + return isset( self::$blocks[ $block_name ] ); } /** @@ -74,57 +72,35 @@ public static function is_registered_block( string $block_name ): bool { * @param string $block_name Name of the block. */ public static function get_data_source_type( string $block_name ): ?string { - $config = self::get_configuration( $block_name ); + $config = self::get_block_configuration( $block_name ); if ( ! $config ) { return null; } - $queries = $config['queries']; - if ( count( $queries ) === 0 ) { + $query = $config['queries'][ ConfigRegistry::DISPLAY_QUERY_KEY ] ?? null; + if ( ! ( $query instanceof QueryInterface ) ) { return null; } - $data_source_type = null; - foreach ( $queries as $query ) { - if ( ! $query instanceof HttpQueryContext ) { - continue; - } - - $data_source_type = $query->get_data_source()->get_service(); - if ( $data_source_type ) { - break; - } - } - - return $data_source_type; + return $query->get_data_source()->get_service_name(); } /** * Return an unprivileged representation of the data sources that can be * displayed in settings screens. * - * @return UiDisplayableInterface[] + * @return array> Data source properties for UI display. */ - public static function get_data_sources_displayable(): array { + public static function get_data_sources_as_array(): array { $data_sources = []; - foreach ( self::$configurations as $config ) { + foreach ( self::$blocks as $config ) { foreach ( $config['queries'] as $query ) { - if ( ! $query instanceof HttpQueryContext ) { - continue; - } - $data_source = $query->get_data_source(); - - if ( $data_source instanceof UiDisplayableInterface ) { - $data_source_array = $data_source->to_array(); - if ( isset( $data_source_array['uuid'] ) ) { - $data_sources[ $data_source_array['uuid'] ] = $data_source->to_ui_display(); - } - } + $data_sources[] = $data_source->to_array(); } } - return array_values( $data_sources ); + return $data_sources; } } diff --git a/inc/Editor/BlockPatterns/BlockPatterns.php b/inc/Editor/BlockPatterns/BlockPatterns.php index 78a95414..a53086e0 100644 --- a/inc/Editor/BlockPatterns/BlockPatterns.php +++ b/inc/Editor/BlockPatterns/BlockPatterns.php @@ -4,7 +4,7 @@ defined( 'ABSPATH' ) || exit(); -use RemoteDataBlocks\Config\QueryContext\QueryContextInterface; +use RemoteDataBlocks\Config\Query\QueryInterface; use RemoteDataBlocks\Editor\DataBinding\BlockBindings; use function register_block_pattern; @@ -67,12 +67,12 @@ private static function populate_template( string $template_name, array $attribu * Register a default block pattern for a remote data block that can be used * even when no other patterns are available (e.g., in the item list view). * - * @param string $block_name The block name. - * @param string $block_title The block title. - * @param QueryContextInterface $display_query The display query. + * @param string $block_name The block name. + * @param string $block_title The block title. + * @param QueryInterface $display_query The display query. * @return string The registered pattern name. */ - public static function register_default_block_pattern( string $block_name, string $block_title, QueryContextInterface $display_query ): string { + public static function register_default_block_pattern( string $block_name, string $block_title, QueryInterface $display_query ): string { self::load_templates(); // Loop through output variables and generate a pattern. Each text field will @@ -90,10 +90,18 @@ public static function register_default_block_pattern( string $block_name, strin 'paragraphs' => [], ]; - foreach ( $display_query->output_schema['mappings'] as $field => $var ) { + $output_schema = $display_query->get_output_schema(); + + foreach ( $output_schema['type'] as $field => $var ) { $name = isset( $var['name'] ) ? $var['name'] : $field; + // The types handled here should align with the constants defined in + // src/blocks/remote-data-container/config/constants.ts switch ( $var['type'] ) { + case 'email_address': + case 'integer': + case 'markdown': + case 'number': case 'string': // Attempt to autodetect headings. $normalized_name = trim( strtolower( $name ) ); @@ -108,14 +116,6 @@ public static function register_default_block_pattern( string $block_name, strin ]; break; - case 'markdown': - case 'base64': - case 'currency': - $bindings['paragraphs'][] = [ - 'content' => [ $field, $name ], - ]; - break; - case 'image_alt': $bindings['image']['alt'] = [ $field, $name ]; break; diff --git a/inc/Editor/DataBinding/BlockBindings.php b/inc/Editor/DataBinding/BlockBindings.php index 820e6364..9e6efbd8 100644 --- a/inc/Editor/DataBinding/BlockBindings.php +++ b/inc/Editor/DataBinding/BlockBindings.php @@ -4,6 +4,7 @@ defined( 'ABSPATH' ) || exit(); +use RemoteDataBlocks\Editor\BlockManagement\ConfigRegistry; use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; use RemoteDataBlocks\Logging\LoggerManager; use WP_Block; @@ -109,100 +110,68 @@ public static function inject_context_for_synced_patterns( array $block_type_arg * are defined in the query configuration. The block editor determines if an * override is applied. */ - private static function apply_query_input_overrides( array $query_input, array $overrides, string $block_name ): array { - $query_input_overrides = []; + private static function apply_query_input_overrides( array $input_variables, array $overrides, string $block_name ): array { + $resolved_overrides = []; - foreach ( $overrides as $key => $override ) { - // Override was provided, but query input does not have the key. - if ( ! isset( $query_input[ $key ] ) ) { + foreach ( $overrides as $input_var_name => $override ) { + if ( empty( $override['source'] ?? '' ) || empty( $override['sourceType'] ?? '' ) ) { continue; } $override_value = ''; - switch ( $override['type'] ) { + switch ( $override['sourceType'] ) { // Source the input variable override from a query variable. + case 'page': case 'query_var': - $override_value = get_query_var( $override['target'], '' ); - break; - case 'url': - $override_value = get_query_var( $key, '' ); + $override_value = get_query_var( $override['source'], '' ); break; } if ( ! empty( $override_value ) ) { - $query_input_overrides[ $key ] = $override_value; + $resolved_overrides[ $input_var_name ] = $override_value; } } /** - * Filter the query input overrides for a block binding. + * Filter the resolved query input overrides for a block binding. * - * @param array $query_input_overrides The query input overrides. - * @param array $query_input The original query input. - * @param string $block_name The block name. + * @param array $resolved_overrides The resolved query input overrides. + * @param array $input_variables The original query input variables. + * @param string $block_name The block name. */ - $overrides = apply_filters( + $resolved_overrides = apply_filters( 'remote_data_blocks_query_input_overrides', - $query_input_overrides, - $query_input, + $resolved_overrides, + $input_variables, $block_name ); - return array_merge( $query_input, $overrides ); + return array_merge( $input_variables, $resolved_overrides ); } - /** - * Transform the query input for a block binding before executing the query if - * a transform function is provided. This allows the query input to be - * transformed in some way before the query is executed. This runs after the - * query input overrides have been applied. - */ - private static function transform_query_input( - array $query_input, - object $query_config - ): array { - $transformed_query_input = []; - - foreach ( $query_config->input_schema as $query_input_key => $query_input_schema ) { - if ( - isset( $query_input_schema['transform'] ) && - is_callable( $query_input_schema['transform'] ) - ) { - $transformed_query_input[ $query_input_key ] = $query_input_schema['transform']( - $query_input - ); - } - } - - return array_merge( $query_input, $transformed_query_input ); - } - - private static function get_query_input( array $block_context, object $query_config ): array { + private static function get_query_input( array $block_context ): array { $block_name = $block_context['blockName']; $query_input = $block_context['queryInput']; $overrides = $block_context['queryInputOverrides'] ?? []; $query_input = self::apply_query_input_overrides( $query_input, $overrides, $block_name ); - $query_input = self::transform_query_input( $query_input, $query_config ); return $query_input; } public static function execute_query( array $block_context, string $operation_name ): array|null { $block_name = $block_context['blockName']; - $block_config = ConfigStore::get_configuration( $block_name ); + $block_config = ConfigStore::get_block_configuration( $block_name ); if ( null === $block_config ) { return null; } try { - $query_config = $block_config['queries']['__DISPLAY__']; - $query_input = self::get_query_input( $block_context, $query_config ); - - $query_runner = $query_config->get_query_runner(); - $query_results = $query_runner->execute( $query_input ); + $query = $block_config['queries'][ ConfigRegistry::DISPLAY_QUERY_KEY ]; + $query_input = self::get_query_input( $block_context ); + $query_results = $query->execute( $query_input ); if ( is_wp_error( $query_results ) ) { self::log_error( 'Error executing query for block binding: ' . $query_results->get_error_message(), $block_name, $operation_name ); diff --git a/inc/Editor/DataBinding/QueryOverrides.php b/inc/Editor/DataBinding/QueryOverrides.php index 984eee34..a421d7e4 100644 --- a/inc/Editor/DataBinding/QueryOverrides.php +++ b/inc/Editor/DataBinding/QueryOverrides.php @@ -12,33 +12,17 @@ public static function init(): void { } /** - * Register the query vars indicated as potential overrides in display queries. + * Register the query vars indicated as potential overrides in configured blocks. */ public static function add_query_vars( array $vars ): array { $query_vars = []; - - // Find all of the query variable overrides defined in display queries. - foreach ( ConfigStore::get_block_names() as $block_name ) { - $config = ConfigStore::get_configuration( $block_name ); - if ( ! isset( $config['queries']['__DISPLAY__']->input_schema ) ) { - continue; - } - - foreach ( $config['queries']['__DISPLAY__']->input_schema as $key => $input_var ) { - if ( ! isset( $input_var['overrides'] ) ) { - continue; - } - - foreach ( $input_var['overrides'] as $override ) { - switch ( $override['type'] ?? '' ) { - case 'query_var': - $query_vars[] = $override['target']; - break; - case 'url': - $query_vars[] = $key; - break; - } + foreach ( ConfigStore::get_block_configurations() as $config ) { + foreach ( $config['query_input_overrides'] as $override ) { + switch ( $override['source_type'] ?? '' ) { + case 'query_var': + $query_vars[] = $override['source']; + break; } } } diff --git a/inc/ExampleApi/ExampleApi.php b/inc/ExampleApi/ExampleApi.php index a563c5b8..7cbed983 100644 --- a/inc/ExampleApi/ExampleApi.php +++ b/inc/ExampleApi/ExampleApi.php @@ -2,14 +2,23 @@ namespace RemoteDataBlocks\ExampleApi; -use RemoteDataBlocks\ExampleApi\Queries\ExampleApiDataSource; -use RemoteDataBlocks\ExampleApi\Queries\ExampleApiGetRecordQuery; -use RemoteDataBlocks\ExampleApi\Queries\ExampleApiGetTableQuery; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Config\Query\HttpQuery; +use RemoteDataBlocks\ExampleApi\Queries\ExampleApiQueryRunner; use function register_remote_data_block; -use function register_remote_data_list_query; +/** + * This example API is bundled with the plugin to provide a working demonstration + * of Remote Data Blocks without requiring an external API. It is backed by a + * flat file bundled with the plugin, so it can be used without an internet + * connection and without reliance on an external server. + * + * It can be disabled with the following code snippet: + * + * add_filter( 'remote_data_blocks_register_example_block', '__return_false' ); + */ class ExampleApi { - private static string $block_name = 'Conference Event'; + private static string $block_title = 'Conference Event'; public static function init(): void { add_action( 'init', [ __CLASS__, 'register_remote_data_block' ] ); @@ -30,15 +39,87 @@ public static function register_remote_data_block(): void { return; } - $data_source = ExampleApiDataSource::from_array( [ - 'uuid' => 'bf4bc2b4-c06a-40d2-80f2-a682d81d63f5', - 'service' => 'example_api', + $data_source = HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Example API (Conference Event)', + 'endpoint' => 'https://example.com/api/v1', // dummy URL + ], ] ); - - $get_record_query = new ExampleApiGetRecordQuery( $data_source ); - $get_table_query = new ExampleApiGetTableQuery( $data_source ); - register_remote_data_block( self::$block_name, $get_record_query ); - register_remote_data_list_query( self::$block_name, $get_table_query ); + $get_record_query = HttpQuery::from_array( [ + 'data_source' => $data_source, + 'input_schema' => [ + 'record_id' => [ + 'name' => 'Record ID', + 'type' => 'id', + ], + ], + 'output_schema' => [ + 'type' => [ + 'id' => [ + 'name' => 'Record ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'path' => '$.fields.Activity', + 'type' => 'string', + ], + 'location' => [ + 'name' => 'Location', + 'path' => '$.fields.Location', + 'type' => 'string', + ], + 'event_type' => [ + 'name' => 'Event type', + 'path' => '$.fields.Type', + 'type' => 'string', + ], + ], + ], + 'query_runner' => new ExampleApiQueryRunner(), + ] ); + + $get_table_query = HttpQuery::from_array( [ + 'data_source' => $data_source, + 'input_schema' => [], + 'output_schema' => [ + 'is_collection' => true, + 'path' => '$.records[*]', + 'type' => [ + 'record_id' => [ + 'name' => 'Record ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'title' => [ + 'name' => 'Title', + 'path' => '$.fields.Activity', + 'type' => 'string', + ], + 'location' => [ + 'name' => 'Location', + 'path' => '$.fields.Location', + 'type' => 'string', + ], + 'event_type' => [ + 'name' => 'Event type', + 'path' => '$.fields.Type', + 'type' => 'string', + ], + ], + ], + 'query_runner' => new ExampleApiQueryRunner(), + ] ); + + register_remote_data_block( [ + 'title' => self::$block_title, + 'queries' => [ + 'display' => $get_record_query, + 'list' => $get_table_query, + ], + ] ); } } diff --git a/inc/ExampleApi/Queries/ExampleApiDataSource.php b/inc/ExampleApi/Queries/ExampleApiDataSource.php deleted file mode 100644 index d002bc52..00000000 --- a/inc/ExampleApi/Queries/ExampleApiDataSource.php +++ /dev/null @@ -1,28 +0,0 @@ - [ - 'name' => 'Record ID', - 'overrides' => [ - [ - 'target' => 'utm_content', - 'type' => 'query_var', - ], - ], - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - public function get_query_name(): string { - return 'Get event'; - } - - public function get_query_runner(): QueryRunnerInterface { - return new ExampleApiQueryRunner( $this ); - } -} diff --git a/inc/ExampleApi/Queries/ExampleApiGetTableQuery.php b/inc/ExampleApi/Queries/ExampleApiGetTableQuery.php deleted file mode 100644 index 78e2f48e..00000000 --- a/inc/ExampleApi/Queries/ExampleApiGetTableQuery.php +++ /dev/null @@ -1,45 +0,0 @@ - '$.records[*]', - 'is_collection' => true, - 'mappings' => [ - 'record_id' => [ - 'name' => 'Record ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.fields.Activity', - 'type' => 'string', - ], - 'location' => [ - 'name' => 'Location', - 'path' => '$.fields.Location', - 'type' => 'string', - ], - 'type' => [ - 'name' => 'Type', - 'path' => '$.fields.Type', - 'type' => 'string', - ], - ], - ]; - } - - public function get_query_name(): string { - return 'List events'; - } - - public function get_query_runner(): QueryRunnerInterface { - return new ExampleApiQueryRunner( $this ); - } -} diff --git a/inc/ExampleApi/Queries/ExampleApiQueryRunner.php b/inc/ExampleApi/Queries/ExampleApiQueryRunner.php index 1a17ead1..c20d3a39 100644 --- a/inc/ExampleApi/Queries/ExampleApiQueryRunner.php +++ b/inc/ExampleApi/Queries/ExampleApiQueryRunner.php @@ -2,6 +2,7 @@ namespace RemoteDataBlocks\ExampleApi\Queries; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; use RemoteDataBlocks\ExampleApi\Data\ExampleApiData; use WP_Error; @@ -13,12 +14,12 @@ * * Execute the query by making an internal REST API request. This allows the * example API to work when running locally (inside a container). Otherwise, - * there would be a mismatch between the public address (e.g., localhost:888) and - * what is reachable inside a container. + * there would be a mismatch between the public address (e.g., localhost:8888) + * and what is reachable inside a container. * */ class ExampleApiQueryRunner extends QueryRunner { - protected function get_raw_response_data( array $input_variables ): array|WP_Error { + protected function get_raw_response_data( HttpQueryInterface $query, array $input_variables ): array|WP_Error { if ( isset( $input_variables['record_id'] ) ) { return [ 'metadata' => [], diff --git a/inc/Formatting/FieldFormatter.php b/inc/Formatting/FieldFormatter.php new file mode 100644 index 00000000..f86571ac --- /dev/null +++ b/inc/Formatting/FieldFormatter.php @@ -0,0 +1,31 @@ +getTextAttribute( NumberFormatter::CURRENCY_CODE ); + return numfmt_format_currency( $format, (float) $value, $currency_code ); + } + + /** + * Format markdown as HTML. + */ + public static function format_markdown( string $value ): string { + return Parsedown::instance()->text( $value ); + } +} diff --git a/inc/Integrations/Airtable/AirtableDataSource.php b/inc/Integrations/Airtable/AirtableDataSource.php index aa251685..6861e369 100644 --- a/inc/Integrations/Airtable/AirtableDataSource.php +++ b/inc/Integrations/Airtable/AirtableDataSource.php @@ -3,117 +3,52 @@ namespace RemoteDataBlocks\Integrations\Airtable; use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Config\Query\HttpQuery; +use RemoteDataBlocks\Validation\Types; use WP_Error; class AirtableDataSource extends HttpDataSource { + protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE; protected const SERVICE_SCHEMA_VERSION = 1; - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'service' => [ - 'type' => 'string', - 'const' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - ], - 'service_schema_version' => [ - 'type' => 'integer', - 'const' => self::SERVICE_SCHEMA_VERSION, - ], - 'access_token' => [ 'type' => 'string' ], - 'base' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'string' ], - 'name' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ], - 'tables' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'string' ], - 'name' => [ - 'type' => 'string', - 'required' => false, - ], - 'output_query_mappings' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'key' => [ 'type' => 'string' ], - 'name' => [ - 'type' => 'string', - 'required' => false, - ], - 'type' => [ - 'type' => 'string', - 'required' => false, - ], - 'path' => [ - 'type' => 'string', - 'required' => false, - ], - 'prefix' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ], - ], - ], - ], - ], - 'display_name' => [ - 'type' => 'string', - 'required' => true, - ], - ], - ]; - - public function get_display_name(): string { - return sprintf( 'Airtable (%s)', $this->config['display_name'] ?? $this->config['base']['name'] ); + protected static function get_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'access_token' => Types::string(), + 'base' => Types::object( [ + 'id' => Types::string(), + 'name' => Types::nullable( Types::string() ), + ] ), + 'display_name' => Types::string(), + 'tables' => Types::list_of( + Types::object( [ + 'id' => Types::id(), + 'name' => Types::nullable( Types::string() ), + 'output_query_mappings' => Types::list_of( + Types::object( [ + 'key' => Types::string(), + 'name' => Types::nullable( Types::string() ), + 'path' => Types::nullable( Types::json_path() ), + 'type' => Types::nullable( Types::string() ), + ] ) + ), + ] ) + ), + ] ); } - public function get_endpoint(): string { - return 'https://api.airtable.com/v0/' . $this->config['base']['id']; - } - - public function get_request_headers(): array|WP_Error { - return [ - 'Authorization' => sprintf( 'Bearer %s', $this->config['access_token'] ), - 'Content-Type' => 'application/json', - ]; - } - - public static function create( string $access_token, string $base_id, ?array $tables = [], ?string $display_name = null ): self { - return parent::from_array([ - 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'access_token' => $access_token, - 'base' => [ 'id' => $base_id ], - 'tables' => $tables, - 'display_name' => $display_name, - ]); - } - - public function to_ui_display(): array { + protected static function map_service_config( array $service_config ): array { return [ - 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'base' => [ - 'id' => $this->config['base']['id'], - 'name' => $this->config['base']['name'] ?? null, + 'display_name' => $service_config['display_name'], + 'endpoint' => sprintf( 'https://api.airtable.com/v0/%s', $service_config['base']['id'] ), + 'request_headers' => [ + 'Authorization' => sprintf( 'Bearer %s', $service_config['access_token'] ), + 'Content-Type' => 'application/json', ], - 'tables' => $this->config['tables'] ?? [], - 'uuid' => $this->config['uuid'] ?? null, - 'display_name' => $this->config['display_name'] ?? null, ]; } - public function ___temp_get_query(): AirtableGetItemQuery|\WP_Error { + public function ___temp_get_query(): HttpQuery|WP_Error { $input_schema = [ 'record_id' => [ 'name' => 'Record ID', @@ -123,7 +58,7 @@ public function ___temp_get_query(): AirtableGetItemQuery|\WP_Error { $output_schema = [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'id' => [ 'name' => 'Record ID', 'path' => '$.id', @@ -132,31 +67,30 @@ public function ___temp_get_query(): AirtableGetItemQuery|\WP_Error { ], ]; - foreach ( $this->config['tables'][0]['output_query_mappings'] as $mapping ) { + foreach ( $this->config['service_config']['tables'][0]['output_query_mappings'] as $mapping ) { $mapping_key = $mapping['key']; - $output_schema['mappings'][ $mapping_key ] = [ + $output_schema['type'][ $mapping_key ] = [ 'name' => $mapping['name'] ?? $mapping_key, 'path' => $mapping['path'] ?? '$.fields["' . $mapping_key . '"]', 'type' => $mapping['type'] ?? 'string', ]; - - if ( 'currency' === $mapping['type'] && isset( $mapping['prefix'] ) ) { - $output_schema['mappings'][ $mapping_key ]['prefix'] = $mapping['prefix']; - } } - return AirtableGetItemQuery::from_array([ + return HttpQuery::from_array( [ 'data_source' => $this, + 'endpoint' => function ( array $input_variables ): string { + return $this->get_endpoint() . '/' . $this->config['service_config']['tables'][0]['id'] . '/' . $input_variables['record_id']; + }, 'input_schema' => $input_schema, 'output_schema' => $output_schema, - ]); + ] ); } - public function ___temp_get_list_query(): AirtableListItemsQuery|\WP_Error { + public function ___temp_get_list_query(): HttpQuery|WP_Error { $output_schema = [ - 'root_path' => '$.records[*]', 'is_collection' => true, - 'mappings' => [ + 'path' => '$.records[*]', + 'type' => [ 'record_id' => [ 'name' => 'Record ID', 'path' => '$.id', @@ -165,19 +99,19 @@ public function ___temp_get_list_query(): AirtableListItemsQuery|\WP_Error { ], ]; - foreach ( $this->config['tables'][0]['output_query_mappings'] as $mapping ) { - $output_schema['mappings'][ $mapping['name'] ] = [ + foreach ( $this->config['service_config']['tables'][0]['output_query_mappings'] as $mapping ) { + $output_schema['type'][ $mapping['name'] ] = [ 'name' => $mapping['name'], 'path' => '$.fields.' . $mapping['name'], 'type' => $mapping['type'] ?? 'string', ]; } - return AirtableListItemsQuery::from_array([ + return HttpQuery::from_array( [ 'data_source' => $this, + 'endpoint' => $this->get_endpoint() . '/' . $this->config['service_config']['tables'][0]['id'], 'input_schema' => [], 'output_schema' => $output_schema, - 'query_name' => $this->config['tables'][0]['name'], - ]); + ] ); } } diff --git a/inc/Integrations/Airtable/AirtableGetItemQuery.php b/inc/Integrations/Airtable/AirtableGetItemQuery.php deleted file mode 100644 index 3de4bdff..00000000 --- a/inc/Integrations/Airtable/AirtableGetItemQuery.php +++ /dev/null @@ -1,16 +0,0 @@ -get_data_source()->to_array(); - return $this->get_data_source()->get_endpoint() . '/' . $data_source_config['tables'][0]['id'] . '/' . $input_variables['record_id']; - } - - public function get_query_name(): string { - return $this->config['query_name'] ?? 'Get item'; - } -} diff --git a/inc/Integrations/Airtable/AirtableIntegration.php b/inc/Integrations/Airtable/AirtableIntegration.php index 8df9ea0f..f4fad38e 100644 --- a/inc/Integrations/Airtable/AirtableIntegration.php +++ b/inc/Integrations/Airtable/AirtableIntegration.php @@ -2,34 +2,45 @@ namespace RemoteDataBlocks\Integrations\Airtable; -use RemoteDataBlocks\Logging\LoggerManager; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; class AirtableIntegration { public static function init(): void { - $data_sources = DataSourceCrud::get_data_sources( REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE ); + $data_source_configs = DataSourceCrud::get_configs_by_service( REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE ); - foreach ( $data_sources as $config ) { - self::register_blocks_for_airtable_data_source( $config ); + foreach ( $data_source_configs as $config ) { + $data_source = AirtableDataSource::from_array( $config ); + self::register_block_for_airtable_data_source( $data_source ); } } - private static function register_blocks_for_airtable_data_source( array $config ): void { - /** @var AirtableDataSource $airtable_data_source */ - $airtable_data_source = AirtableDataSource::from_array( $config ); - - $block_name = $airtable_data_source->get_display_name(); - $query = $airtable_data_source->___temp_get_query(); - $list_query = $airtable_data_source->___temp_get_list_query(); - - if ( is_wp_error( $query ) || is_wp_error( $list_query ) ) { - LoggerManager::instance()->error( 'Failed to get query for Airtable block' ); - return; - } + public static function register_block_for_airtable_data_source( AirtableDataSource $data_source, array $block_overrides = [] ): void { + register_remote_data_block( + array_merge( + [ + 'title' => $data_source->get_display_name(), + 'queries' => [ + 'display' => $data_source->___temp_get_query(), + 'list' => $data_source->___temp_get_list_query(), + ], + ], + $block_overrides + ) + ); + } - register_remote_data_block( $block_name, $query ); - register_remote_data_list_query( $block_name, $list_query ); - - LoggerManager::instance()->info( 'Registered Airtable block', [ 'block_name' => $block_name ] ); + public static function register_loop_block_for_airtable_data_source( AirtableDataSource $data_source, array $block_overrides = [] ): void { + register_remote_data_block( + array_merge( + [ + 'title' => sprintf( '%s Loop', $data_source->get_display_name() ), + 'loop' => true, + 'queries' => [ + 'display' => $data_source->___temp_get_list_query(), + ], + ], + $block_overrides + ) + ); } } diff --git a/inc/Integrations/Airtable/AirtableListItemsQuery.php b/inc/Integrations/Airtable/AirtableListItemsQuery.php deleted file mode 100644 index 92b6ab99..00000000 --- a/inc/Integrations/Airtable/AirtableListItemsQuery.php +++ /dev/null @@ -1,16 +0,0 @@ -get_data_source()->to_array(); - return $this->get_data_source()->get_endpoint() . '/' . $data_source_config['tables'][0]['id']; - } - - public function get_query_name(): string { - return $this->config['query_name'] ?? 'List items'; - } -} diff --git a/inc/Integrations/GenericHttp/GenericHttpDataSource.php b/inc/Integrations/GenericHttp/GenericHttpDataSource.php deleted file mode 100644 index 335f699b..00000000 --- a/inc/Integrations/GenericHttp/GenericHttpDataSource.php +++ /dev/null @@ -1,90 +0,0 @@ - 'object', - 'properties' => [ - 'service' => [ - 'type' => 'string', - 'const' => REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE, - ], - 'service_schema_version' => [ - 'type' => 'integer', - 'const' => self::SERVICE_SCHEMA_VERSION, - ], - 'auth' => [ - 'type' => 'object', - 'properties' => [ - 'type' => [ - 'type' => 'string', - 'enum' => [ 'basic', 'bearer', 'api-key', 'none' ], - ], - 'value' => [ - 'type' => 'string', - 'sanitize' => false, - ], - 'key' => [ - 'type' => 'string', - 'sanitize' => false, - 'required' => false, - ], - 'add_to' => [ - 'type' => 'string', - 'enum' => [ 'header', 'query' ], - 'required' => false, - ], - ], - ], - 'url' => [ - 'type' => 'string', - 'callback' => '\RemoteDataBlocks\Validation\is_url', - 'sanitize' => 'sanitize_url', - ], - 'display_name' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ]; - - public function get_display_name(): string { - return 'HTTP Connection (' . $this->config['display_name'] . ')'; - } - - public function get_endpoint(): string { - return $this->config['url']; - } - - public function get_request_headers(): array|WP_Error { - return [ - 'Accept' => 'application/json', - ]; - } - - public static function create( string $url, string $auth, string $display_name ): self { - return parent::from_array([ - 'display_name' => $display_name, - 'service' => REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE, - 'url' => $url, - 'auth' => $auth, - ]); - } - - public function to_ui_display(): array { - return [ - 'display_name' => $this->get_display_name(), - 'service' => REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE, - 'url' => $this->config['url'], - 'auth_type' => $this->config['auth']['type'], - 'uuid' => $this->config['uuid'] ?? null, - ]; - } -} diff --git a/inc/Integrations/GitHub/GitHubDataSource.php b/inc/Integrations/GitHub/GitHubDataSource.php index be0e8785..f67406fb 100644 --- a/inc/Integrations/GitHub/GitHubDataSource.php +++ b/inc/Integrations/GitHub/GitHubDataSource.php @@ -3,72 +3,34 @@ namespace RemoteDataBlocks\Integrations\GitHub; use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use WP_Error; +use RemoteDataBlocks\Validation\Types; class GitHubDataSource extends HttpDataSource { protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_GITHUB_SERVICE; protected const SERVICE_SCHEMA_VERSION = 1; - - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'service' => [ - 'type' => 'string', - 'const' => REMOTE_DATA_BLOCKS_GITHUB_SERVICE, - ], - 'service_schema_version' => [ - 'type' => 'integer', - 'const' => self::SERVICE_SCHEMA_VERSION, - ], - 'repo_owner' => [ 'type' => 'string' ], - 'repo_name' => [ 'type' => 'string' ], - 'ref' => [ 'type' => 'string' ], - ], - ]; - - public function get_display_name(): string { - return sprintf( 'GitHub: %s/%s (%s)', $this->config['repo_owner'], $this->config['repo_name'], $this->config['ref'] ); - } - public function get_hash(): string { - return hash( 'sha256', sprintf( '%s/%s/%s', $this->config['repo_owner'], $this->config['repo_name'], $this->config['ref'] ) ); + protected static function get_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'display_name' => Types::string(), + 'repo_owner' => Types::string(), + 'repo_name' => Types::string(), + 'ref' => Types::string(), + ] ); } - public function get_endpoint(): string { - return sprintf( - 'https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1', - $this->config['repo_owner'], - $this->config['repo_name'], - $this->config['ref'] - ); - } - - public function get_request_headers(): array|WP_Error { + protected static function map_service_config( array $service_config ): array { return [ - 'Accept' => 'application/vnd.github+json', + 'display_name' => $service_config['display_name'], + 'endpoint' => sprintf( + 'https://api.github.com/repos/%s/%s/git/trees/%s?recursive=1', + $service_config['repo_owner'], + $service_config['repo_name'], + $service_config['ref'] + ), + 'request_headers' => [ + 'Accept' => 'application/vnd.github+json', + ], ]; } - - public function get_repo_owner(): string { - return $this->config['repo_owner']; - } - - public function get_repo_name(): string { - return $this->config['repo_name']; - } - - public function get_ref(): string { - return $this->config['ref']; - } - - public static function create( string $repo_owner, string $repo_name, string $ref, string $uuid ): self { - return parent::from_array([ - 'display_name' => sprintf( 'GitHub: %s/%s (%s)', $repo_owner, $repo_name, $ref ), - 'service' => REMOTE_DATA_BLOCKS_GITHUB_SERVICE, - 'repo_owner' => $repo_owner, - 'repo_name' => $repo_name, - 'ref' => $ref, - 'uuid' => $uuid, - ]); - } } diff --git a/inc/Integrations/Google/Sheets/GoogleSheetsDataSource.php b/inc/Integrations/Google/Sheets/GoogleSheetsDataSource.php index 7ef75274..0260e1bb 100644 --- a/inc/Integrations/Google/Sheets/GoogleSheetsDataSource.php +++ b/inc/Integrations/Google/Sheets/GoogleSheetsDataSource.php @@ -4,121 +4,55 @@ use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\Integrations\Google\Auth\GoogleAuth; -use WP_Error; +use RemoteDataBlocks\Validation\Types; class GoogleSheetsDataSource extends HttpDataSource { protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE; protected const SERVICE_SCHEMA_VERSION = 1; - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'credentials' => [ - 'type' => 'object', - 'properties' => [ - 'type' => [ 'type' => 'string' ], - 'project_id' => [ 'type' => 'string' ], - 'private_key_id' => [ 'type' => 'string' ], - 'private_key' => [ - 'type' => 'string', - 'sanitize' => false, - ], - 'client_email' => [ - 'type' => 'string', - 'callback' => 'is_email', - 'sanitize' => 'sanitize_email', - ], - 'client_id' => [ 'type' => 'string' ], - 'auth_uri' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'token_uri' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'auth_provider_x509_cert_url' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'client_x509_cert_url' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'universe_domain' => [ 'type' => 'string' ], - ], - ], - 'display_name' => [ - 'type' => 'string', - 'required' => false, - ], - 'spreadsheet' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ - 'type' => 'string', - 'required' => false, // Spreadsheet name is not required to fetch data. - ], - 'id' => [ 'type' => 'string' ], - ], - ], - 'sheet' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - 'id' => [ 'type' => 'integer' ], - ], - ], - ], - ]; - - public function get_display_name(): string { - return sprintf( 'Google Sheets: %s', $this->config['display_name'] ); - } - - public function get_endpoint(): string { - return sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s', $this->config['spreadsheet']['id'] ); - } - - public function get_request_headers(): array|WP_Error { - $access_token = GoogleAuth::generate_token_from_service_account_key( - $this->config['credentials'], - GoogleAuth::GOOGLE_SHEETS_SCOPES - ); - - if ( is_wp_error( $access_token ) ) { - return $access_token; - } - - return [ - 'Authorization' => sprintf( 'Bearer %s', $access_token ), - 'Content-Type' => 'application/json', - ]; - } - - public static function create( array $credentials, string $spreadsheet_id, string $display_name ): self|WP_Error { - return parent::from_array([ - 'service' => REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE, - 'credentials' => $credentials, - 'display_name' => $display_name, - 'spreadsheet' => [ - 'name' => '', - 'id' => $spreadsheet_id, - ], - 'sheet' => [ - 'name' => '', - 'id' => 0, - ], - ]); + protected static function get_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'credentials' => Types::object( [ + 'type' => Types::string(), + 'project_id' => Types::string(), + 'private_key_id' => Types::string(), + 'private_key' => Types::skip_sanitize( Types::string() ), + 'client_email' => Types::email_address(), + 'client_id' => Types::string(), + 'auth_uri' => Types::url(), + 'token_uri' => Types::url(), + 'auth_provider_x509_cert_url' => Types::url(), + 'client_x509_cert_url' => Types::url(), + 'universe_domain' => Types::string(), + ] ), + 'display_name' => Types::string(), + 'spreadsheet' => Types::object( [ + 'id' => Types::id(), + 'name' => Types::nullable( Types::string() ), + ] ), + 'sheet' => Types::object( [ + 'id' => Types::integer(), + 'name' => Types::string(), + ] ), + ] ); } - public function to_ui_display(): array { + protected static function map_service_config( array $service_config ): array { return [ - 'display_name' => $this->get_display_name(), - 'service' => REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE, - 'spreadsheet' => [ 'name' => $this->config['spreadsheet_id'] ], - 'sheet' => [ 'name' => '' ], - 'uuid' => $this->config['uuid'] ?? null, + 'display_name' => $service_config['display_name'], + 'endpoint' => sprintf( 'https://sheets.googleapis.com/v4/spreadsheets/%s', $service_config['spreadsheet']['id'] ), + 'request_headers' => function () use ( $service_config ): array { + $access_token = GoogleAuth::generate_token_from_service_account_key( + $service_config['credentials'], + GoogleAuth::GOOGLE_SHEETS_SCOPES + ); + + return [ + 'Authorization' => sprintf( 'Bearer %s', $access_token ), + 'Content-Type' => 'application/json', + ]; + }, ]; } } diff --git a/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CGetProductQuery.php b/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CGetProductQuery.php deleted file mode 100644 index d1ae366b..00000000 --- a/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CGetProductQuery.php +++ /dev/null @@ -1,95 +0,0 @@ - [ - 'name' => 'Product ID', - 'overrides' => [ - [ - 'target' => 'utm_content', - 'type' => 'query_var', - ], - ], - 'type' => 'id', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'is_collection' => false, - 'mappings' => [ - 'id' => [ - 'name' => 'Product ID', - 'path' => '$.id', - 'type' => 'id', - ], - 'name' => [ - 'name' => 'Name', - 'path' => '$.name', - 'type' => 'string', - ], - 'longDescription' => [ - 'name' => 'Long Description', - 'path' => '$.longDescription', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'Price', - 'path' => '$.price', - 'type' => 'string', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'path' => '$.imageGroups[0].images[0].link', - 'type' => 'image_url', - ], - 'image_alt_text' => [ - 'name' => 'Image Alt Text', - 'path' => '$.imageGroups[0].images[0].alt', - 'type' => 'image_alt', - ], - ], - ]; - } - - public function get_request_headers( array $input_variables ): array|WP_Error { - $data_source_config = $this->get_data_source()->to_array(); - $data_source_endpoint = $this->get_data_source()->get_endpoint(); - - $access_token = SalesforceB2CAuth::generate_token( - $data_source_endpoint, - $data_source_config['organization_id'], - $data_source_config['client_id'], - $data_source_config['client_secret'] - ); - - if ( is_wp_error( $access_token ) ) { - return $access_token; - } - - return [ - 'Content-Type' => 'application/json', - 'Authorization' => sprintf( 'Bearer %s', $access_token ), - ]; - } - - public function get_endpoint( array $input_variables ): string { - $data_source_endpoint = $this->get_data_source()->get_endpoint(); - $data_source_config = $this->get_data_source()->to_array(); - - return sprintf( '%s/product/shopper-products/v1/organizations/%s/products/%s?siteId=RefArchGlobal', $data_source_endpoint, $data_source_config['organization_id'], $input_variables['product_id'] ); - } - - public function get_query_name(): string { - return $this->config['query_name'] ?? 'Get item'; - } -} diff --git a/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CSearchProductsQuery.php b/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CSearchProductsQuery.php deleted file mode 100644 index b465eb34..00000000 --- a/inc/Integrations/SalesforceB2C/Queries/SalesforceB2CSearchProductsQuery.php +++ /dev/null @@ -1,83 +0,0 @@ - [ - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => '$.hits[*]', - 'is_collection' => true, - 'mappings' => [ - 'product_id' => [ - 'name' => 'Product ID', - 'path' => '$.productId', - 'type' => 'id', - ], - 'name' => [ - 'name' => 'Product name', - 'path' => '$.productName', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'Item price', - 'path' => '$.price', - 'type' => 'price', - ], - 'image_url' => [ - 'name' => 'Item image URL', - 'path' => '$.image.link', - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_endpoint( array $input_variables ): string { - $data_source_endpoint = $this->get_data_source()->get_endpoint(); - $data_source_config = $this->get_data_source()->to_array(); - - return sprintf( - '%s/search/shopper-search/v1/organizations/%s/product-search?siteId=RefArchGlobal&q=%s', - $data_source_endpoint, - $data_source_config['organization_id'], - urlencode( $input_variables['search_terms'] ) - ); - } - - public function get_request_headers( array $input_variables ): array|WP_Error { - $data_source_config = $this->get_data_source()->to_array(); - $data_source_endpoint = $this->get_data_source()->get_endpoint(); - - $access_token = SalesforceB2CAuth::generate_token( - $data_source_endpoint, - $data_source_config['organization_id'], - $data_source_config['client_id'], - $data_source_config['client_secret'] - ); - - if ( is_wp_error( $access_token ) ) { - return $access_token; - } - - return [ - 'Content-Type' => 'application/json', - 'Authorization' => sprintf( 'Bearer %s', $access_token ), - ]; - } - - public function get_query_name(): string { - return 'Search products'; - } -} diff --git a/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php b/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php index fb9133c6..53501e0f 100644 --- a/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php +++ b/inc/Integrations/SalesforceB2C/SalesforceB2CDataSource.php @@ -3,8 +3,7 @@ namespace RemoteDataBlocks\Integrations\SalesforceB2C; use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use WP_Error; - +use RemoteDataBlocks\Validation\Types; use function plugins_url; defined( 'ABSPATH' ) || exit(); @@ -13,62 +12,25 @@ class SalesforceB2CDataSource extends HttpDataSource { protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE; protected const SERVICE_SCHEMA_VERSION = 1; - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'service' => [ - 'type' => 'string', - 'const' => REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE, - ], - 'service_schema_version' => [ - 'type' => 'integer', - 'const' => self::SERVICE_SCHEMA_VERSION, - ], - 'shortcode' => [ 'type' => 'string' ], - 'organization_id' => [ 'type' => 'string' ], - 'client_id' => [ 'type' => 'string' ], - 'client_secret' => [ 'type' => 'string' ], - 'display_name' => [ - 'type' => 'string', - 'required' => true, - ], - ], - ]; - - public function get_display_name(): string { - return 'Salesforce B2C (' . $this->config['uuid'] . ')'; - } - - public function get_endpoint(): string { - return sprintf( 'https://%s.api.commercecloud.salesforce.com', $this->config['shortcode'] ); - } - - public function get_request_headers(): array|WP_Error { - return [ - 'Content-Type' => 'application/json', - ]; - } - - public function get_image_url(): string { - return plugins_url( './assets/salesforce_commerce_cloud_logo.png', __FILE__ ); - } - - public static function create( string $shortcode, string $organization_id, string $client_id, string $client_secret, ?string $display_name = null ): self { - return parent::from_array([ - 'service' => REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE, - 'shortcode' => $shortcode, - 'organization_id' => $organization_id, - 'client_id' => $client_id, - 'client_secret' => $client_secret, - 'display_name' => $display_name, - ]); + protected static function get_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'display_name' => Types::string(), + 'client_id' => Types::string(), + 'client_secret' => Types::string(), + 'organization_id' => Types::string(), + 'shortcode' => Types::string(), + ] ); } - public function to_ui_display(): array { + protected static function map_service_config( array $service_config ): array { return [ - 'service' => REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE, - 'store_name' => $this->config['store_name'], - 'uuid' => $this->config['uuid'] ?? null, + 'display_name' => $service_config['display_name'], + 'endpoint' => sprintf( 'https://%s.api.commercecloud.salesforce.com', $service_config['shortcode'] ), + 'image_url' => plugins_url( './assets/salesforce_commerce_cloud_logo.png', __FILE__ ), + 'request_headers' => [ + 'Content-Type' => 'application/json', + ], ]; } } diff --git a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php b/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php index a355ec6c..c28cc553 100644 --- a/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php +++ b/inc/Integrations/SalesforceB2C/SalesforceB2CIntegration.php @@ -2,29 +2,156 @@ namespace RemoteDataBlocks\Integrations\SalesforceB2C; -use RemoteDataBlocks\Integrations\SalesforceB2C\Queries\SalesforceB2CGetProductQuery; -use RemoteDataBlocks\Integrations\SalesforceB2C\Queries\SalesforceB2CSearchProductsQuery; -use RemoteDataBlocks\Logging\LoggerManager; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; +use RemoteDataBlocks\Integrations\SalesforceB2C\Auth\SalesforceB2CAuth; +use WP_Error; class SalesforceB2CIntegration { public static function init(): void { - $data_sources = DataSourceCrud::get_data_sources( REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE ); + $data_source_configs = DataSourceCrud::get_configs_by_service( REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE ); - foreach ( $data_sources as $config ) { - self::register_blocks_for_salesforce_data_source( $config ); + foreach ( $data_source_configs as $config ) { + $data_source = SalesforceB2CDataSource::from_array( $config ); + self::register_blocks_for_salesforce_data_source( $data_source ); } } - private static function register_blocks_for_salesforce_data_source( array $config ): void { - $salesforce_data_source = SalesforceB2CDataSource::from_array( $config ); - $salesforce_get_product_query = new SalesforceB2CGetProductQuery( $salesforce_data_source ); - $salesforce_search_products_query = new SalesforceB2CSearchProductsQuery( $salesforce_data_source ); + private static function get_queries( SalesforceB2CDataSource $data_source ): array { + $base_endpoint = $data_source->get_endpoint(); + $service_config = $data_source->to_array()['service_config']; - $block_name = $salesforce_data_source->get_display_name(); - register_remote_data_block( $block_name, $salesforce_get_product_query ); - register_remote_data_search_query( $block_name, $salesforce_search_products_query ); + $get_request_headers = function () use ( $base_endpoint, $service_config ): array|WP_Error { + $access_token = SalesforceB2CAuth::generate_token( + $base_endpoint, + $service_config['organization_id'], + $service_config['client_id'], + $service_config['client_secret'] + ); + $request_headers = [ 'Content-Type' => 'application/json' ]; - LoggerManager::instance()->info( 'Registered Salesforce B2C block', [ 'block_name' => $block_name ] ); + if ( is_wp_error( $access_token ) ) { + return $access_token; + } + + return array_merge( $request_headers, [ 'Authorization' => sprintf( 'Bearer %s', $access_token ) ] ); + }; + + return [ + 'display' => HttpQuery::from_array( [ + 'data_source' => $data_source, + 'endpoint' => function ( array $input_variables ) use ( $base_endpoint, $service_config ): string { + return sprintf( + '%s/product/shopper-products/v1/organizations/%s/products/%s?siteId=RefArchGlobal', + $base_endpoint, + $service_config['organization_id'], + $input_variables['product_id'] + ); + }, + 'input_schema' => [ + 'product_id' => [ + 'name' => 'Product ID', + 'type' => 'id', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'id' => [ + 'name' => 'Product ID', + 'path' => '$.id', + 'type' => 'id', + ], + 'name' => [ + 'name' => 'Name', + 'path' => '$.name', + 'type' => 'string', + ], + 'longDescription' => [ + 'name' => 'Long Description', + 'path' => '$.longDescription', + 'type' => 'string', + ], + 'price' => [ + 'name' => 'Price', + 'path' => '$.price', + 'type' => 'string', + ], + 'image_url' => [ + 'name' => 'Image URL', + 'path' => '$.imageGroups[0].images[0].link', + 'type' => 'image_url', + ], + 'image_alt_text' => [ + 'name' => 'Image Alt Text', + 'path' => '$.imageGroups[0].images[0].alt', + 'type' => 'image_alt', + ], + ], + ], + 'request_headers' => $get_request_headers, + ] ), + 'search' => HttpQuery::from_array( [ + 'data_source' => $data_source, + 'endpoint' => function ( array $input_variables ) use ( $base_endpoint, $service_config ): string { + return sprintf( + '%s/search/shopper-search/v1/organizations/%s/product-search?siteId=RefArchGlobal&q=%s', + $base_endpoint, + $service_config['organization_id'], + urlencode( $input_variables['search_terms'] ) + ); + }, + 'input_schema' => [ + 'search_terms' => [ + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'path' => '$.hits[*]', + 'is_collection' => true, + 'type' => [ + 'product_id' => [ + 'name' => 'product id', + 'path' => '$.productid', + 'type' => 'id', + ], + 'name' => [ + 'name' => 'product name', + 'path' => '$.productname', + 'type' => 'string', + ], + 'price' => [ + 'name' => 'item price', + 'path' => '$.price', + 'type' => 'price', + ], + 'image_url' => [ + 'name' => 'item image url', + 'path' => '$.image.link', + 'type' => 'image_url', + ], + ], + ], + 'request_headers' => $get_request_headers, + ] ), + ]; + } + + public static function register_blocks_for_salesforce_data_source( SalesforceB2CDataSource $data_source ): void { + register_remote_data_block( + [ + 'title' => $data_source->get_display_name(), + 'queries' => self::get_queries( $data_source ), + 'query_input_overrides' => [ + [ + 'query' => 'display', + 'source' => 'utm_content', + 'source_type' => 'query_var', + 'target' => 'product_id', + 'target_type' => 'input_var', + ], + ], + ] + ); } } diff --git a/inc/Integrations/Shopify/Queries/GetProductById.graphql b/inc/Integrations/Shopify/Queries/GetProductById.graphql new file mode 100644 index 00000000..3ddf683a --- /dev/null +++ b/inc/Integrations/Shopify/Queries/GetProductById.graphql @@ -0,0 +1,29 @@ +query GetProductById($id: ID!) { + product(id: $id) { + id + descriptionHtml + title + featuredImage { + url + altText + } + priceRange { + maxVariantPrice { + amount + } + } + variants(first: 10) { + edges { + node { + id + availableForSale + image { + url + } + sku + title + } + } + } + } +} diff --git a/inc/Integrations/Shopify/Queries/SearchProducts.graphql b/inc/Integrations/Shopify/Queries/SearchProducts.graphql new file mode 100644 index 00000000..186bdcbc --- /dev/null +++ b/inc/Integrations/Shopify/Queries/SearchProducts.graphql @@ -0,0 +1,23 @@ +query SearchProducts($search_terms: String) { + products(first: 10, query: $search_terms, sortKey: BEST_SELLING) { + edges { + node { + id + title + descriptionHtml + priceRange { + maxVariantPrice { + amount + } + } + images(first: 1) { + edges { + node { + originalSrc + } + } + } + } + } + } +} diff --git a/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php b/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php deleted file mode 100644 index 1f743701..00000000 --- a/inc/Integrations/Shopify/Queries/ShopifyGetProductQuery.php +++ /dev/null @@ -1,99 +0,0 @@ - [ - 'type' => 'id', - 'overrides' => [ - [ - 'target' => 'product-details', - 'type' => 'url', - ], - ], - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => null, - 'is_collection' => false, - 'mappings' => [ - 'description' => [ - 'name' => 'Product description', - 'path' => '$.data.product.descriptionHtml', - 'type' => 'string', - ], - 'title' => [ - 'name' => 'Title', - 'path' => '$.data.product.title', - 'type' => 'string', - ], - 'image_url' => [ - 'name' => 'Image URL', - 'path' => '$.data.product.featuredImage.url', - 'type' => 'image_url', - ], - 'image_alt_text' => [ - 'name' => 'Image Alt Text', - 'path' => '$.data.product.featuredImage.altText', - 'type' => 'image_alt', - ], - 'price' => [ - 'name' => 'Item price', - 'path' => '$.data.product.priceRange.maxVariantPrice.amount', - 'type' => 'currency', - ], - 'variant_id' => [ - 'name' => 'Variant ID', - 'path' => '$.data.product.variants.edges[0].node.id', - 'type' => 'id', - ], - 'details_button_url' => [ - 'name' => 'Details URL', - 'generate' => function ( $data ) { - return '/path-to-page/' . $data['data']['product']['id']; - }, - 'type' => 'button_url', - ], - ], - ]; - } - - public function get_query(): string { - return 'query GetProductById($id: ID!) { - product(id: $id) { - id - descriptionHtml - title - featuredImage { - url - altText - } - priceRange { - maxVariantPrice { - amount - } - } - variants(first: 10) { - edges { - node { - id - availableForSale - image { - url - } - sku - title - } - } - } - } - }'; - } -} diff --git a/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php b/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php deleted file mode 100644 index 2aeaceb5..00000000 --- a/inc/Integrations/Shopify/Queries/ShopifySearchProductsQuery.php +++ /dev/null @@ -1,74 +0,0 @@ - [ - 'type' => 'string', - ], - ]; - } - - public function get_output_schema(): array { - return [ - 'root_path' => '$.data.products.edges[*]', - 'is_collection' => true, - 'mappings' => [ - 'id' => [ - 'name' => 'Product ID', - 'path' => '$.node.id', - 'type' => 'id', - ], - 'title' => [ - 'name' => 'Product title', - 'path' => '$.node.title', - 'type' => 'string', - ], - 'price' => [ - 'name' => 'Item price', - 'path' => '$.node.priceRange.maxVariantPrice.amount', - 'type' => 'currency', - ], - 'image_url' => [ - 'name' => 'Item image URL', - 'path' => '$.node.images.edges[0].node.originalSrc', - 'type' => 'image_url', - ], - ], - ]; - } - - public function get_query(): string { - return 'query SearchProducts($search_terms: String!) { - products(first: 10, query: $search_terms, sortKey: BEST_SELLING) { - edges { - node { - id - title - descriptionHtml - priceRange { - maxVariantPrice { - amount - } - } - images(first: 1) { - edges { - node { - originalSrc - } - } - } - } - } - } - }'; - } - - public function get_query_name(): string { - return 'Search products'; - } -} diff --git a/inc/Integrations/Shopify/ShopifyDataSource.php b/inc/Integrations/Shopify/ShopifyDataSource.php index 3e60e7ff..0887b88c 100644 --- a/inc/Integrations/Shopify/ShopifyDataSource.php +++ b/inc/Integrations/Shopify/ShopifyDataSource.php @@ -3,8 +3,7 @@ namespace RemoteDataBlocks\Integrations\Shopify; use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use WP_Error; - +use RemoteDataBlocks\Validation\Types; use function plugins_url; defined( 'ABSPATH' ) || exit(); @@ -13,60 +12,24 @@ class ShopifyDataSource extends HttpDataSource { protected const SERVICE_NAME = REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE; protected const SERVICE_SCHEMA_VERSION = 1; - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'service' => [ - 'type' => 'string', - 'const' => REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, - ], - 'service_schema_version' => [ - 'type' => 'integer', - 'const' => self::SERVICE_SCHEMA_VERSION, - ], - 'access_token' => [ 'type' => 'string' ], - 'store_name' => [ 'type' => 'string' ], - 'display_name' => [ - 'type' => 'string', - 'required' => false, - ], - ], - ]; - - public function get_display_name(): string { - return 'Shopify (' . $this->config['display_name'] . ')'; - } - - public function get_endpoint(): string { - return 'https://' . $this->config['store_name'] . '.myshopify.com/api/2024-04/graphql.json'; - } - - public function get_request_headers(): array|WP_Error { - return [ - 'Content-Type' => 'application/json', - 'X-Shopify-Storefront-Access-Token' => $this->config['access_token'], - ]; - } - - public function get_image_url(): string { - return plugins_url( './assets/shopify_logo_black.png', __FILE__ ); - } - - public static function create( string $access_token, string $store_name, ?string $display_name = null ): self { - return parent::from_array([ - 'display_name' => $display_name ?? 'Shopify (' . $store_name . ')', - 'service' => REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, - 'access_token' => $access_token, - 'store_name' => $store_name, - ]); + protected static function get_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'access_token' => Types::skip_sanitize( Types::string() ), + 'display_name' => Types::string(), + 'store_name' => Types::string(), + ] ); } - public function to_ui_display(): array { + protected static function map_service_config( array $service_config ): array { return [ - 'display_name' => $this->get_display_name(), - 'service' => REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, - 'store_name' => $this->config['store_name'], - 'uuid' => $this->config['uuid'] ?? null, + 'display_name' => $service_config['display_name'], + 'endpoint' => 'https://' . $service_config['store_name'] . '.myshopify.com/api/2024-04/graphql.json', + 'image_url' => plugins_url( './assets/shopify_logo_black.png', __FILE__ ), + 'request_headers' => [ + 'Content-Type' => 'application/json', + 'X-Shopify-Storefront-Access-Token' => $service_config['access_token'], + ], ]; } } diff --git a/inc/Integrations/Shopify/ShopifyIntegration.php b/inc/Integrations/Shopify/ShopifyIntegration.php index 4fffd0aa..994bda20 100644 --- a/inc/Integrations/Shopify/ShopifyIntegration.php +++ b/inc/Integrations/Shopify/ShopifyIntegration.php @@ -2,35 +2,129 @@ namespace RemoteDataBlocks\Integrations\Shopify; -use RemoteDataBlocks\Integrations\Shopify\Queries\ShopifyGetProductQuery; -use RemoteDataBlocks\Integrations\Shopify\Queries\ShopifySearchProductsQuery; -use RemoteDataBlocks\Logging\LoggerManager; +use RemoteDataBlocks\Config\Query\GraphqlQuery; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; + use function register_remote_data_block; -use function register_remote_data_block_pattern; -use function register_remote_data_search_query; class ShopifyIntegration { public static function init(): void { - $data_sources = DataSourceCrud::get_data_sources( REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE ); + $data_source_configs = DataSourceCrud::get_configs_by_service( REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE ); - foreach ( $data_sources as $config ) { - self::register_blocks_for_shopify_data_source( $config ); + foreach ( $data_source_configs as $config ) { + $data_source = ShopifyDataSource::from_array( $config ); + self::register_blocks_for_shopify_data_source( $data_source ); } } - private static function register_blocks_for_shopify_data_source( array $config ): void { - $shopify_data_source = ShopifyDataSource::from_array( $config ); - $shopify_search_products_query = new ShopifySearchProductsQuery( $shopify_data_source ); - $shopify_get_product_query = new ShopifyGetProductQuery( $shopify_data_source ); - - $block_name = $shopify_data_source->get_display_name(); - $block_pattern = file_get_contents( __DIR__ . '/Patterns/product-teaser.html' ); + public static function get_queries( ShopifyDataSource $data_source ): array { + return [ + 'shopify_get_product' => GraphqlQuery::from_array( [ + 'data_source' => $data_source, + 'input_schema' => [ + 'id' => [ + 'type' => 'id', + ], + ], + 'output_schema' => [ + 'is_collection' => false, + 'type' => [ + 'description' => [ + 'name' => 'Product description', + 'path' => '$.data.product.descriptionHtml', + 'type' => 'string', + ], + 'details_button_url' => [ + 'name' => 'Details URL', + 'generate' => function ( $data ): string { + return '/path-to-page/' . $data['data']['product']['id']; + }, + 'type' => 'button_url', + ], + 'image_alt_text' => [ + 'name' => 'Image Alt Text', + 'path' => '$.data.product.featuredImage.altText', + 'type' => 'image_alt', + ], + 'image_url' => [ + 'name' => 'Image URL', + 'path' => '$.data.product.featuredImage.url', + 'type' => 'image_url', + ], + 'price' => [ + 'name' => 'Item price', + 'path' => '$.data.product.priceRange.maxVariantPrice.amount', + 'type' => 'currency_in_current_locale', + ], + 'title' => [ + 'name' => 'Title', + 'path' => '$.data.product.title', + 'type' => 'string', + ], + 'variant_id' => [ + 'name' => 'Variant ID', + 'path' => '$.data.product.variants.edges[0].node.id', + 'type' => 'id', + ], + ], + ], + 'graphql_query' => file_get_contents( __DIR__ . '/Queries/GetProductById.graphql' ), + ] ), + 'shopify_search_products' => GraphqlQuery::from_array( [ + 'data_source' => $data_source, + 'input_schema' => [ + 'search_terms' => [ + 'type' => 'string', + ], + ], + 'output_schema' => [ + 'path' => '$.data.products.edges[*]', + 'is_collection' => true, + 'type' => [ + 'id' => [ + 'name' => 'Product ID', + 'path' => '$.node.id', + 'type' => 'id', + ], + 'image_url' => [ + 'name' => 'Item image URL', + 'path' => '$.node.images.edges[0].node.originalSrc', + 'type' => 'image_url', + ], + 'price' => [ + 'name' => 'Item price', + 'path' => '$.node.priceRange.maxVariantPrice.amount', + 'type' => 'currency_in_current_locale', + ], + 'title' => [ + 'name' => 'Product title', + 'path' => '$.node.title', + 'type' => 'string', + ], + ], + ], + 'graphql_query' => file_get_contents( __DIR__ . '/Queries/SearchProducts.graphql' ), + ] ), + ]; + } - register_remote_data_block( $block_name, $shopify_get_product_query ); - register_remote_data_search_query( $block_name, $shopify_search_products_query ); - register_remote_data_block_pattern( $block_name, 'Shopify Product Teaser', $block_pattern ); + public static function register_blocks_for_shopify_data_source( ShopifyDataSource $data_source ): void { + $block_title = $data_source->get_display_name(); + $queries = self::get_queries( $data_source ); - LoggerManager::instance()->info( 'Registered Shopify block', [ 'block_name' => $block_name ] ); + register_remote_data_block( [ + 'title' => $block_title, + 'queries' => [ + 'display' => $queries['shopify_get_product'], + 'list' => $queries['shopify_search_products'], + ], + 'patterns' => [ + [ + 'html' => file_get_contents( __DIR__ . '/Patterns/product-teaser.html' ), + 'role' => 'inner_blocks', + 'title' => 'Shopify Product Teaser', + ], + ], + ] ); } } diff --git a/inc/Integrations/constants.php b/inc/Integrations/constants.php index 3ea6d947..57e9352a 100644 --- a/inc/Integrations/constants.php +++ b/inc/Integrations/constants.php @@ -21,7 +21,7 @@ const REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP = [ REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE => \RemoteDataBlocks\Integrations\Airtable\AirtableDataSource::class, - REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE => \RemoteDataBlocks\Integrations\GenericHttp\GenericHttpDataSource::class, + REMOTE_DATA_BLOCKS_GENERIC_HTTP_SERVICE => \RemoteDataBlocks\Config\DataSource\HttpDataSource::class, REMOTE_DATA_BLOCKS_GITHUB_SERVICE => \RemoteDataBlocks\Integrations\GitHub\GitHubDataSource::class, REMOTE_DATA_BLOCKS_GOOGLE_SHEETS_SERVICE => \RemoteDataBlocks\Integrations\Google\Sheets\GoogleSheetsDataSource::class, REMOTE_DATA_BLOCKS_SALESFORCE_B2C_SERVICE => \RemoteDataBlocks\Integrations\SalesforceB2C\SalesforceB2CDataSource::class, diff --git a/inc/REST/DataSourceController.php b/inc/REST/DataSourceController.php index 0d375414..3fbcbaad 100644 --- a/inc/REST/DataSourceController.php +++ b/inc/REST/DataSourceController.php @@ -61,6 +61,10 @@ public function register_routes(): void { 'required' => true, 'enum' => REMOTE_DATA_BLOCKS__SERVICES, ], + 'service_config' => [ + 'type' => 'object', + 'required' => true, + ], ], ] ); @@ -73,6 +77,12 @@ public function register_routes(): void { 'methods' => 'PUT', 'callback' => [ $this, 'update_item' ], 'permission_callback' => [ $this, 'update_item_permissions_check' ], + 'args' => [ + 'uuid' => [ + 'type' => 'string', + 'required' => true, + ], + ], ] ); @@ -84,6 +94,12 @@ public function register_routes(): void { 'methods' => 'DELETE', 'callback' => [ $this, 'delete_item' ], 'permission_callback' => [ $this, 'delete_item_permissions_check' ], + 'args' => [ + 'uuid' => [ + 'type' => 'string', + 'required' => true, + ], + ], ] ); } @@ -96,7 +112,7 @@ public function register_routes(): void { */ public function create_item( $request ) { $data_source_properties = $request->get_json_params(); - $item = DataSourceCrud::register_new_data_source( $data_source_properties ); + $item = DataSourceCrud::create_config( $data_source_properties ); TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', array_merge( [ 'data_source_type' => $data_source_properties['service'], @@ -113,33 +129,26 @@ public function create_item( $request ) { * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ public function get_items( $request ) { - $code_configured_data_sources = ConfigStore::get_data_sources_displayable(); - $ui_configured_data_sources = DataSourceCrud::get_data_sources_list(); + $code_configured_data_sources = ConfigStore::get_data_sources_as_array(); + $ui_configured_data_sources = DataSourceCrud::get_configs(); /** - * Quick and dirty de-duplication of data sources by uuid. + * Quick and dirty de-duplication of data sources. If the data source does + * not have a UUID (because it is registered in code), we generate an + * identifier based on the display name and service name. * * UI configured data sources take precedence over code configured ones * here due to the ordering of the two arrays passed to array_reduce. - * - * @todo: refactor this out in the near future in favor of an upstream - * single source of truth for data source configurations */ - $data_sources = array_values(array_reduce( + $data_sources = array_values( array_reduce( array_merge( $code_configured_data_sources, $ui_configured_data_sources ), function ( $acc, $item ) { - // Check if item with the same UUID already exists - if ( isset( $acc[ $item['uuid'] ] ) ) { - // Merge the properties of the existing item with the new one - $acc[ $item['uuid'] ] = array_merge( $acc[ $item['uuid'] ], $item ); - } else { - // Otherwise, add the new item - $acc[ $item['uuid'] ] = $item; - } + $identifier = $item['uuid'] ?? md5( sprintf( '%s_%s', $item['service_config']['display_name'], $item['service'] ) ); + $acc[ $identifier ] = $item; return $acc; }, [] - )); + ) ); // Tracks Analytics. Only once per day to reduce noise. $track_transient_key = 'remotedatablocks_view_data_sources_tracked'; @@ -164,64 +173,31 @@ function ( $acc, $item ) { * @param WP_REST_Request $request Full details about the request. * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. */ + public function get_item( $request ) { + $response = DataSourceCrud::get_config_by_uuid( $request->get_param( 'uuid' ) ); + return rest_ensure_response( $response ); + } + + /** + * Updates a single item. + * + * @param WP_REST_Request $request Full details about the request. + * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure. + */ public function update_item( $request ) { - $current_uuid = $request->get_param( 'uuid' ); $data_source_properties = $request->get_json_params(); + $item = DataSourceCrud::update_config_by_uuid( $request->get_param( 'uuid' ), $data_source_properties ); - // Extract new UUID if provided - $new_uuid = $data_source_properties['newUUID'] ?? null; - - // Retrieve the current data sources and the item by its current UUID - $data_sources = DataSourceCrud::get_data_sources(); - $item = DataSourceCrud::get_item_by_uuid( $data_sources, $current_uuid ); - - // Handle item not found - if ( ! $item ) { - return new WP_Error( - 'data_source_not_found', - __( 'Data source not found.', 'remote-data-blocks' ), - [ 'status' => 404 ] - ); - } - - // Handle new UUID conflicts - if ( $new_uuid && $new_uuid !== $current_uuid ) { - // Ensure no conflict with existing UUID - if ( DataSourceCrud::get_item_by_uuid( $data_sources, $new_uuid ) ) { - return new WP_Error( - 'uuid_conflict', - __( 'The new UUID already exists.', 'remote-data-blocks' ), - [ 'status' => 409 ] - ); - } - } - - // Set the new UUID in the properties - $data_source_properties['uuid'] = $new_uuid; - - // Merge the updated properties with the existing item - $updated_item = array_merge( $item, $data_source_properties ); - - // Pass the original UUID when updating the item to avoid duplication - $result = DataSourceCrud::update_item_by_uuid( $current_uuid, $updated_item, $current_uuid ); - - if ( is_wp_error( $result ) ) { - return $result; // Return WP_Error if update fails + if ( is_wp_error( $item ) ) { + return $item; // Return WP_Error if update fails } - // Log the update action - TracksAnalytics::record_event( - 'remotedatablocks_data_source_interaction', - array_merge( - [ - 'data_source_type' => $data_source_properties['service'], - 'action' => 'update', - ], - $this->get_data_source_interaction_track_props( $data_source_properties ) - ) - ); + TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', array_merge( [ + 'data_source_type' => $item['service'], + 'action' => 'update', + ], $this->get_data_source_interaction_track_props( $item ) ) ); - return rest_ensure_response( $result ); + return rest_ensure_response( $item ); } /** @@ -232,7 +208,7 @@ public function update_item( $request ) { */ public function delete_item( $request ) { $data_source_properties = $request->get_json_params(); - $result = DataSourceCrud::delete_item_by_uuid( $request->get_param( 'uuid' ) ); + $result = DataSourceCrud::delete_config_by_uuid( $request->get_param( 'uuid' ) ); // Tracks Analytics. TracksAnalytics::record_event( 'remotedatablocks_data_source_interaction', [ @@ -243,7 +219,6 @@ public function delete_item( $request ) { return rest_ensure_response( $result ); } - // These all require manage_options for now, but we can adjust as needed public function get_item_permissions_check( $request ) { @@ -270,7 +245,7 @@ private function get_data_source_interaction_track_props( $data_source_propertie $props = []; if ( 'generic-http' === $data_source_properties['service'] ) { - $auth = $data_source_properties['auth']; + $auth = $data_source_properties['service_config']['auth'] ?? []; $props['authentication_type'] = $auth['type'] ?? ''; $props['api_key_location'] = $auth['addTo'] ?? ''; } diff --git a/inc/REST/RemoteDataController.php b/inc/REST/RemoteDataController.php index 3d227a3d..7d429616 100644 --- a/inc/REST/RemoteDataController.php +++ b/inc/REST/RemoteDataController.php @@ -34,7 +34,7 @@ public static function register_rest_routes(): void { return strval( $value ); }, 'validate_callback' => function ( $value ) { - return null !== ConfigStore::get_configuration( $value ); + return null !== ConfigStore::get_block_configuration( $value ); }, ], 'query_key' => [ @@ -58,15 +58,14 @@ public static function execute_query( WP_REST_Request $request ): array|WP_Error $query_key = $request->get_param( 'query_key' ); $query_input = $request->get_param( 'query_input' ); - $block_config = ConfigStore::get_configuration( $block_name ); + $block_config = ConfigStore::get_block_configuration( $block_name ); $query = $block_config['queries'][ $query_key ]; // The frontend might send more input variables than the query needs or // expects, so only include those defined by the query. - $query_input = array_intersect_key( $query_input, $query->input_schema ); + $query_input = array_intersect_key( $query_input, $query->get_input_schema() ); - $query_runner = $query->get_query_runner(); - $query_result = $query_runner->execute( $query_input ); + $query_result = $query->execute( $query_input ); if ( is_wp_error( $query_result ) ) { $logger = LoggerManager::instance(); diff --git a/inc/Sanitization/Sanitizer.php b/inc/Sanitization/Sanitizer.php index 67ced150..8bb6193d 100644 --- a/inc/Sanitization/Sanitizer.php +++ b/inc/Sanitization/Sanitizer.php @@ -2,94 +2,146 @@ namespace RemoteDataBlocks\Sanitization; +use RemoteDataBlocks\Validation\Types; +use RemoteDataBlocks\Validation\Validator; + /** * Sanitizer class. */ class Sanitizer implements SanitizerInterface { - /** - * The sanitization schema. - * - * @var array - */ - private array $schema; - /** * @inheritDoc */ - public function __construct( array $schema ) { - $this->schema = $schema; + public function __construct( private array $schema ) {} + + public function sanitize( mixed $data ): mixed { + return $this->sanitize_type( $this->schema, $data ); } /** - * @inheritDoc + * Sanitize a value recursively against a schema. + * + * @param array $type The schema to sanitize against. + * @param mixed $value The value to sanitize. + * @return mixed Sanitized value. */ - public function sanitize( array $data ): array { - if ( ! isset( $this->schema['type'] ) || 'object' !== $this->schema['type'] || ! isset( $this->schema['properties'] ) ) { - return []; + private function sanitize_type( array $type, mixed $value = null ): mixed { + if ( ! Types::is_sanitizable( $type ) ) { + return $value; + } + + if ( Types::is_nullable( $type ) && empty( $value ) ) { + return null; } - return $this->sanitize_config( $data, $this->schema['properties'] ); + if ( Types::is_primitive( $type ) ) { + return self::sanitize_primitive_type( Types::get_type_name( $type ), $value ); + } + + return $this->sanitize_non_primitive_type( $type, $value ); } - /** - * Sanitize the config, recursively if necessary, according to the schema. - * - * @param array $config The config to sanitize. - * @param array $schema The schema to use for sanitization. - * @return array The sanitized config. - */ - private function sanitize_config( array $config, array $schema ): array { - $sanitized = []; - - foreach ( $schema as $key => $field_schema ) { - if ( ! isset( $config[ $key ] ) ) { - continue; - } - - $value = $config[ $key ]; - - if ( isset( $field_schema['sanitize'] ) && false === $field_schema['sanitize'] ) { - $sanitized[ $key ] = $value; - continue; - } - - if ( isset( $field_schema['sanitize'] ) ) { - $sanitized[ $key ] = call_user_func( $field_schema['sanitize'], $value ); - continue; - } - - switch ( $field_schema['type'] ) { - case 'string': - $sanitized[ $key ] = sanitize_text_field( $value ); - break; - case 'integer': - $sanitized[ $key ] = intval( $value ); - break; - case 'boolean': - $sanitized[ $key ] = (bool) $value; - break; - case 'array': - if ( is_array( $value ) ) { - if ( isset( $field_schema['items'] ) ) { - $sanitized[ $key ] = array_map(function ( $item ) use ( $field_schema ) { - if ( is_array( $item ) && isset( $field_schema['items']['properties'] ) ) { - return $this->sanitize_config( $item, $field_schema['items']['properties'] ); - } - return $this->sanitize_config( $item, $field_schema['items'] ); - }, $value); - } else { - $sanitized[ $key ] = array_map( 'sanitize_text_field', $value ); - } - } - break; - case 'object': - if ( is_array( $value ) && isset( $field_schema['properties'] ) ) { - $sanitized[ $key ] = $this->sanitize_config( $value, $field_schema['properties'] ); - } - break; - } + private function sanitize_non_primitive_type( array $type, mixed $value ): mixed { + // Not all types support sanitization. We sanitize what we can. + switch ( Types::get_type_name( $type ) ) { + case 'const': + return Types::get_type_args( $type ); + + case 'list_of': + if ( ! is_array( $value ) || ! array_is_list( $value ) ) { + return []; + } + + $member_type = Types::get_type_args( $type ); + return array_map( function ( mixed $item ) use ( $member_type ): mixed { + return $this->sanitize_type( $member_type, $item ); + }, $value ); + + case 'object': + if ( ! Validator::check_iterable_object( $value ) ) { + return []; + } + + $sanitized_object = []; + foreach ( Types::get_type_args( $type ) as $key => $value_type ) { + $sanitized_object[ $key ] = $this->sanitize_type( $value_type, $this->get_object_key( $value, $key ) ); + } + + return $sanitized_object; + + case 'record': + if ( ! Validator::check_iterable_object( $value ) ) { + return []; + } + + $type_args = Types::get_type_args( $type ); + $key_type = $type_args[0]; + $value_type = $type_args[1]; + + foreach ( $value as $key => $record_value ) { + $sanitized_key = $this->sanitize_type( $key_type, $key ); + $sanitized_record_value = $this->sanitize_type( $value_type, $record_value ); + $value[ $sanitized_key ] = $sanitized_record_value; + } + + return $value; + + case 'string_matching': + $regex = Types::get_type_args( $type ); + if ( preg_match( $regex, strval( $value ) ) ) { + return $value; + } + + return null; + + default: + return $value; + } + } + + public static function sanitize_primitive_type( string $type_name, mixed $value ): mixed { + // If the value is an array, just take the first element. + if ( is_array( $value ) ) { + return self::sanitize_primitive_type( $type_name, $value[0] ?? null ); } - return $sanitized; + // Not all types support sanitization. We sanitize what we can. + switch ( $type_name ) { + case 'boolean': + return (bool) $value; + + case 'integer': + return intval( $value ); + + case 'null': + return null; + + case 'string': + return sanitize_text_field( strval( $value ) ); + + case 'button_text': + case 'html': + case 'id': + case 'image_alt': + case 'json_path': + case 'markdown': + case 'uuid': + return strval( $value ); + + case 'email_address': + return sanitize_email( $value ); + + case 'button_url': + case 'image_url': + case 'url': + return sanitize_url( strval( $value ) ); + + default: + return $value; + } + } + + private function get_object_key( mixed $data, string $key ): mixed { + return is_array( $data ) && array_key_exists( $key, $data ) ? $data[ $key ] : null; } } diff --git a/inc/Sanitization/SanitizerInterface.php b/inc/Sanitization/SanitizerInterface.php index c84b8c93..9f4fef47 100644 --- a/inc/Sanitization/SanitizerInterface.php +++ b/inc/Sanitization/SanitizerInterface.php @@ -10,7 +10,6 @@ interface SanitizerInterface { /** * Constructor. - * */ public function __construct( array $schema ); @@ -18,7 +17,7 @@ public function __construct( array $schema ); * Sanitize data according to a schema. * * - * @return array The sanitized data. + * @return mixed The sanitized data. */ - public function sanitize( array $data ): array; + public function sanitize( mixed $data ): mixed; } diff --git a/inc/Validation/ConfigSchemas.php b/inc/Validation/ConfigSchemas.php new file mode 100644 index 00000000..068f9602 --- /dev/null +++ b/inc/Validation/ConfigSchemas.php @@ -0,0 +1,244 @@ + Types::object( [ + ConfigRegistry::DISPLAY_QUERY_KEY => Types::instance_of( QueryInterface::class ), + ConfigRegistry::LIST_QUERY_KEY => Types::nullable( Types::instance_of( QueryInterface::class ) ), + ConfigRegistry::SEARCH_QUERY_KEY => Types::nullable( Types::instance_of( QueryInterface::class ) ), + ] ), + 'query_input_overrides' => Types::nullable( + Types::list_of( + Types::object( [ + 'query' => Types::enum( ConfigRegistry::DISPLAY_QUERY_KEY ), // only display query for now + 'source' => Types::string(), // e.g., the name of the query var + 'source_type' => Types::enum( 'page', 'query_var' ), + 'target' => Types::string(), // e.g., input variable name + 'target_type' => Types::const( 'input_var' ), + ] ), + ) + ), + 'loop' => Types::nullable( Types::boolean() ), + 'pages' => Types::nullable( + Types::list_of( + Types::object( [ + 'allow_nested_paths' => Types::nullable( Types::boolean() ), + 'slug' => Types::string(), + 'title' => Types::nullable( Types::string() ), + ] ) + ) + ), + 'patterns' => Types::nullable( + Types::list_of( + Types::object( [ + 'html' => Types::html(), + 'role' => Types::nullable( Types::enum( 'inner_blocks' ) ), + 'title' => Types::string(), + ] ) + ) + ), + 'title' => Types::string(), + ] ); + } + + private static function generate_graphql_query_config_schema(): array { + return Types::merge_object_types( + self::get_http_query_config_schema(), + Types::object( [ + 'graphql_query' => Types::string(), + 'request_method' => Types::nullable( Types::enum( 'GET', 'POST' ) ), + ] ) + ); + } + + private static function generate_http_data_source_config_schema(): array { + return Types::object( [ + 'display_name' => Types::string(), + 'endpoint' => Types::string(), + 'image_url' => Types::nullable( Types::image_url() ), + 'request_headers' => Types::nullable( + Types::one_of( + Types::callable(), + Types::record( Types::string(), Types::string() ), + ) + ), + 'service' => Types::string(), + 'service_config' => Types::record( Types::string(), Types::any() ), + 'uuid' => Types::nullable( Types::uuid() ), + ] ); + } + + private static function generate_http_data_source_service_config_schema(): array { + return Types::object( [ + '__version' => Types::integer(), + 'auth' => Types::nullable( + Types::object( [ + 'add_to' => Types::nullable( Types::enum( 'header', 'query' ) ), + 'key' => Types::nullable( Types::skip_sanitize( Types::string() ) ), + 'type' => Types::enum( 'basic', 'bearer', 'api-key', 'none' ), + 'value' => Types::skip_sanitize( Types::string() ), + ] ) + ), + 'display_name' => Types::string(), + 'endpoint' => Types::url(), + ] ); + } + + private static function generate_http_query_config_schema(): array { + return Types::object( [ + 'cache_ttl' => Types::nullable( Types::one_of( Types::callable(), Types::integer(), Types::null() ) ), + 'data_source' => Types::instance_of( HttpDataSourceInterface::class ), + 'endpoint' => Types::nullable( Types::one_of( Types::callable(), Types::url() ) ), + 'image_url' => Types::nullable( Types::image_url() ), + // NOTE: The "input schema" for a query is not a formal schema like the + // ones generated by this class. It is a simple flat map of string keys and + // primitive values that can be encoded as a PHP associative array or + // another serializable data structure like JSON. + 'input_schema' => Types::nullable( + Types::record( + Types::string(), + Types::object( [ + // TODO: The default value type should match the type specified for + // the current field, but we have no grammar to represent this (refs + // are global, not scoped). We could create a Types::matches_sibling + // helper to handle this, or just do it ad hoc when we validate the + // input schema. + 'default_value' => Types::nullable( Types::any() ), + 'name' => Types::nullable( Types::string() ), + // NOTE: These values are string references to the "core primitive + // types" from our formal schema. Referencing these types allows us + // to use the same validation and sanitization logic. + 'type' => Types::enum( 'boolean', 'id', 'integer', 'null', 'number', 'string' ), + ] ), + ) + ), + // NOTE: The "output schema" for a query is not a formal schema like the + // ones generated by this class. It is, however, more complex than the + // "input schema" so that it can represent nested data structures. + // + // Since we want this "schema" to be serializable and simple to use, we + // have created our own shorthand syntax that effectively maps to our more + // formal types. The formal schema below describes this shorthand syntax. + // + // This allows most "output schemas" to be represented as a PHP associative + // array or another serializable data structure like JSON (unless it uses + // unseriazable types like closures). + 'output_schema' => Types::create_ref( + 'FIELD_SCHEMA', + Types::object( [ + // @see Note above about default value type. + 'default_value' => Types::nullable( Types::any() ), + 'format' => Types::nullable( Types::callable() ), + 'generate' => Types::nullable( Types::callable() ), + 'is_collection' => Types::nullable( Types::boolean() ), + 'name' => Types::nullable( Types::string() ), + 'path' => Types::nullable( Types::json_path() ), + 'type' => Types::one_of( + // NOTE: These values are string references to all of the primitive + // types from our formal schema. Referencing these types allows us to + // use the same validation and sanitization logic for both. This list + // must not contain non-primitive types, because this simple syntax + // cannot accept type arguments. + Types::enum( + 'boolean', + 'integer', + 'null', + 'number', + 'string', + 'button_text', + 'button_url', + 'currency_in_current_locale', + 'email_address', + 'html', + 'id', + 'image_alt', + 'image_url', + 'markdown', + // 'json_path' is omitted since it likely has no user utility. + 'url', + 'uuid', + ), + Types::record( Types::string(), Types::use_ref( 'FIELD_SCHEMA' ) ), // Nested schema! + ), + ] ), + ), + 'preprocess_response' => Types::nullable( Types::callable() ), + 'query_runner' => Types::nullable( Types::instance_of( QueryRunnerInterface::class ) ), + 'request_body' => Types::nullable( + Types::one_of( + Types::callable(), + Types::object( [] ), + ) + ), + 'request_headers' => Types::nullable( + Types::one_of( + Types::callable(), + Types::record( Types::string(), Types::string() ), + ) + ), + 'request_method' => Types::nullable( Types::enum( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ) ), + ] ); + } +} diff --git a/inc/Validation/Types.php b/inc/Validation/Types.php new file mode 100644 index 00000000..55eaeb01 --- /dev/null +++ b/inc/Validation/Types.php @@ -0,0 +1,368 @@ +> */ + private static array $ref_store = []; + + /* === CORE PRIMITIVE TYPES === */ + /* Primitive types do not accept type arguments! */ + + public static function any(): array { + return self::skip_sanitize( self::generate_primitive_type( 'any' ) ); + } + + public static function boolean(): array { + return self::generate_primitive_type( 'boolean' ); + } + + public static function integer(): array { + return self::generate_primitive_type( 'integer' ); + } + + public static function null(): array { + return self::generate_primitive_type( 'null' ); + } + + public static function number(): array { + return self::generate_primitive_type( 'number' ); + } + + public static function string(): array { + return self::generate_primitive_type( 'string' ); + } + + + /* === EXTENDED STRING PRIMITIVE TYPES === */ + /* Primitive types do not accept type arguments! */ + + public static function button_text(): array { + return self::generate_primitive_type( 'button_text' ); + } + + public static function button_url(): array { + return self::generate_primitive_type( 'image_url' ); + } + + /** + * A currency value in the current locale. Since primitive types do not accept + * arguments, this type inherits the locale from the current environment. + */ + public static function currency_in_current_locale(): array { + return self::generate_primitive_type( 'currency_in_current_locale' ); + } + + public static function email_address(): array { + return self::generate_primitive_type( 'email_address' ); + } + + public static function html(): array { + return self::generate_primitive_type( 'html' ); + } + + public static function id(): array { + return self::generate_primitive_type( 'id' ); + } + + public static function image_alt(): array { + return self::generate_primitive_type( 'image_alt' ); + } + + public static function image_url(): array { + return self::generate_primitive_type( 'image_url' ); + } + + public static function json_path(): array { + return self::generate_primitive_type( 'json_path' ); + } + + public static function markdown(): array { + return self::generate_primitive_type( 'json_path' ); + } + + public static function url(): array { + return self::generate_primitive_type( 'url' ); + } + + public static function uuid(): array { + return self::generate_primitive_type( 'uuid' ); + } + + + /* === TRANSFORMATIVE TYPES === */ + + /** + * All types are required by default. This transformation is used to make a type nullable / optional. + */ + public static function nullable( array $type ): array { + self::check_type( $type ); + + return array_merge( $type, [ self::NULLABLE_PROP => true ] ); + } + + /** + * Data is sanitized by default according to its type (e.g., data with a string + * type is passed through `sanitize_text_field`). This transformation is used + * to exempt a primitive type from sanitization. + */ + public static function skip_sanitize( array $type ): array { + self::check_type( $type ); + + if ( ! self::is_primitive( $type ) ) { + throw new \InvalidArgumentException( 'Sanitization can only be skipped for primitive types.' ); + } + + return array_merge( $type, [ self::SANITIZE_PROP => false ] ); + } + + + /* === NON-PRIMITIVE TYPES === */ + + public static function const( mixed $value ): array { + return self::generate_non_primitive_type( 'const', $value ); + } + + public static function enum( string ...$values ): array { + if ( ! is_array( $values ) || empty( $values ) ) { + throw new \InvalidArgumentException( 'An enum type must provide an array of at least one value.' ); + } + + return self::generate_non_primitive_type( 'enum', $values ); + } + + public static function instance_of( string $instance_ref ): array { + return self::generate_non_primitive_type( 'instance_of', $instance_ref ); + } + + public static function list_of( array $member_type ): array { + self::check_type( $member_type ); + + $allowed_non_primitive_member_types = [ 'const', 'enum', 'instance_of', 'object', 'record', 'string_matching' ]; + $is_primitive = self::is_primitive( $member_type ); + $member_type_name = self::get_type_name( $member_type ); + + if ( ! $is_primitive && ! in_array( $member_type_name, $allowed_non_primitive_member_types, true ) ) { + throw new \InvalidArgumentException( sprintf( "Invalid member type '%s' for list_of.", esc_html( $member_type_name ) ) ); + } + + return self::generate_non_primitive_type( 'list_of', $member_type ); + } + + public static function object( array $properties ): array { + return self::generate_non_primitive_type( 'object', $properties ); + } + + public static function one_of( array ...$member_types ): array { + foreach ( $member_types as $member_type ) { + self::check_type( $member_type ); + + $allowed_non_primitive_member_types = [ + 'callable', + 'const', + 'enum', + 'instance_of', + 'object', + 'record', + 'ref', + 'string_matching', + ]; + $is_primitive = self::is_primitive( $member_type ); + $member_type_name = self::get_type_name( $member_type ); + + if ( ! $is_primitive && ! in_array( $member_type_name, $allowed_non_primitive_member_types, true ) ) { + throw new \InvalidArgumentException( sprintf( "Invalid member type '%s' for one_of.", esc_html( $member_type_name ) ) ); + } + } + + return self::generate_non_primitive_type( 'one_of', $member_types ); + } + + public static function record( array $key_type, array $value_type ): array { + self::check_type( $key_type ); + self::check_type( $value_type ); + + $allowed_key_types = [ 'integer', 'string' ]; + $key_type_name = self::get_type_name( $key_type ); + if ( ! in_array( $key_type_name, $allowed_key_types, true ) ) { + throw new \InvalidArgumentException( sprintf( "Invalid key type '%s' for record.", esc_html( $key_type_name ) ) ); + } + + return self::generate_non_primitive_type( 'record', [ $key_type, $value_type ] ); + } + + public static function string_matching( string $regex ): array { + return self::generate_non_primitive_type( 'string_matching', $regex ); + } + + + /* === UNSERIALIZABLE TYPES === */ + + public static function callable(): array { + return self::generate_unserializable_type( 'callable' ); + } + + + /* === REFERENCE TYPES === */ + + /** + * Create a reference to a type that can be used later. + * + * @see use_ref() + */ + public static function create_ref( string $ref, array $type ): array { + self::check_type( $type ); + + $allowed_types = [ 'object' ]; + $type_name = self::get_type_name( $type ); + + if ( ! in_array( $type_name, $allowed_types, true ) ) { + throw new \InvalidArgumentException( sprintf( "Cannot create ref for '%s' type.", esc_html( $type_name ) ) ); + } + + self::$ref_store[ $ref ] = $type; + + return array_merge( $type, [ self::REF_PROP => $ref ] ); + } + + /** + * Refer to an already created type (allows recursive types). + * + * @see create_ref() + */ + public static function use_ref( string $ref ): array { + return self::generate_unserializable_type( 'ref', $ref ); + } + + + /* === HELPER METHODS === */ + /* These methods operate on types, not values! For data validation, use a Validator. */ + + private static function check_type( mixed $type ): void { + if ( ! is_array( $type ) || ! isset( $type[ self::TYPE_PROP ] ) || ! is_string( $type[ self::TYPE_PROP ] ) ) { + throw new \InvalidArgumentException( sprintf( 'A type must be an associative array with a %s property', esc_html( self::TYPE_PROP ) ) ); + } + } + + public static function get_type_args( array $type ): mixed { + self::check_type( $type ); + + if ( self::is_primitive( $type ) ) { + throw new \InvalidArgumentException( 'Primitive types cannot have arguments.' ); + } + + return $type[ self::ARGS_PROP ] ?? null; + } + + public static function get_type_name( array $type ): string { + self::check_type( $type ); + + return $type[ self::TYPE_PROP ]; + } + + public static function is_nullable( array $type ): bool { + self::check_type( $type ); + + return $type[ self::NULLABLE_PROP ] ?? false; + } + + public static function is_primitive( array $type ): bool { + self::check_type( $type ); + + return $type[ self::PRIMITIVE_PROP ] ?? false; + } + + public static function is_sanitizable( array $type ): bool { + self::check_type( $type ); + + return $type[ self::SANITIZE_PROP ] ?? true; + } + + public static function is_serializable( array $type ): bool { + self::check_type( $type ); + + return $type[ self::SERIALIZABLE_PROP ] ?? true; + } + + private static function is_type( string $type_name, array ...$types_to_check ): bool { + return array_reduce( $types_to_check, function ( bool $carry, array $type ) use ( $type_name ): bool { + self::check_type( $type ); + + return $carry && self::get_type_name( $type ) === $type_name; + }, true ); + } + + private static function generate_primitive_type( string $type_name ): array { + return [ + self::PRIMITIVE_PROP => true, + self::TYPE_PROP => $type_name, + ]; + } + + private static function generate_non_primitive_type( string $type_name, mixed $type_args = null ): array { + return [ + self::ARGS_PROP => $type_args, + self::PRIMITIVE_PROP => false, + self::TYPE_PROP => $type_name, + ]; + } + + private static function generate_unserializable_type( string $type_name, mixed $type_args = null ): array { + return [ + self::ARGS_PROP => $type_args, + self::PRIMITIVE_PROP => false, + self::SERIALIZABLE_PROP => false, + self::TYPE_PROP => $type_name, + ]; + } + + /** + * Internal library use only. Types stay as references until they are needed + * for data validation. + */ + public static function load_ref_type( array $ref_type ): array { + self::check_type( $ref_type ); + + if ( ! self::is_type( 'ref', $ref_type ) ) { + throw new \InvalidArgumentException( 'Provided type is not a ref type.' ); + } + + $ref = self::get_type_args( $ref_type ); + if ( ! isset( self::$ref_store[ $ref ] ) ) { + throw new \InvalidArgumentException( sprintf( "Unknown ref '%s'.", esc_html( $ref ) ) ); + } + + return self::$ref_store[ $ref ]; + } + + public static function merge_object_types( array ...$types ): array { + if ( ! self::is_type( 'object', ...$types ) ) { + throw new \InvalidArgumentException( 'Provided types are not all object types.' ); + } + + $merged_properties = array_reduce( $types, function ( array $carry, array $type ): array { + return array_merge( $carry, self::get_type_args( $type ) ); + }, [] ); + + return self::object( $merged_properties ); + } +} diff --git a/inc/Validation/Validator.php b/inc/Validation/Validator.php index 0117325b..cc5fbce0 100644 --- a/inc/Validation/Validator.php +++ b/inc/Validation/Validator.php @@ -2,127 +2,239 @@ namespace RemoteDataBlocks\Validation; +use RemoteDataBlocks\Validation\Types; +use RemoteDataBlocks\Logging\LoggerManager; use WP_Error; +use function is_email; /** * Validator class. */ -class Validator implements ValidatorInterface { - /** - * The validation schema. - * - * @var array - */ - private array $schema; +final class Validator implements ValidatorInterface { + public function __construct( private array $schema, private string $description = 'Validator' ) {} - /** - * @inheritDoc - */ - public function __construct( array $schema ) { - $this->schema = $schema; - } + public function validate( mixed $data ): bool|WP_Error { + $validation = $this->check_type( $this->schema, $data ); - /** - * @inheritDoc - * - * @param array $data The data to validate. - */ - public function validate( array $data ): bool|WP_Error { - return $this->validate_schema( $this->schema, $data ); + if ( is_wp_error( $validation ) ) { + $error_message = sprintf( '[%s] %s', $this->description, $validation->get_error_message() ); + + $child_error = $validation->get_error_data()['child'] ?? null; + while ( is_wp_error( $child_error ) ) { + $error_message .= sprintf( '; %s', $child_error->get_error_message() ); + $child_error = $child_error->get_error_data()['child'] ?? null; + } + + LoggerManager::instance()->error( $error_message ); + return $validation; + } + + return true; } /** - * Validates the schema. + * Validate a value recursively against a schema. * - * @param array $schema The schema to validate against. - * @param mixed $data The data to validate. + * @param array $type The schema to validate against. + * @param mixed $value The value to validate. * @return bool|WP_Error Returns true if the data is valid, otherwise a WP_Error. */ - private function validate_schema( array $schema, mixed $data ): bool|WP_Error { - if ( isset( $schema['required'] ) && false === $schema['required'] && ! isset( $data ) ) { + private function check_type( array $type, mixed $value = null ): bool|WP_Error { + if ( Types::is_nullable( $type ) && is_null( $value ) ) { return true; } - if ( isset( $schema['type'] ) && ! $this->check_type( $data, $schema['type'] ) ) { - // translators: %1$s is the expected PHP data type, %2$s is the actual PHP data type. - return new WP_Error( 'invalid_type', sprintf( __( 'Expected %1$s, got %2$s.', 'remote-data-blocks' ), $schema['type'], gettype( $data ) ), [ 'status' => 400 ] ); - } + if ( Types::is_primitive( $type ) ) { + $type_name = Types::get_type_name( $type ); + if ( $this->check_primitive_type( $type_name, $value ) ) { + return true; + } - if ( isset( $schema['pattern'] ) && is_string( $data ) && ! preg_match( $schema['pattern'], $data ) ) { - // translators: %1$s is the expected regex pattern, %2$s is the actual value. - return new WP_Error( 'invalid_format', sprintf( __( 'Expected %1$s, got %2$s.', 'remote-data-blocks' ), $schema['pattern'], $data ), [ 'status' => 400 ] ); + return $this->create_error( sprintf( 'Value must be a %s', $type_name ), $value ); } - if ( isset( $schema['enum'] ) && ! in_array( $data, $schema['enum'] ) ) { - // translators: %1$s is the expected value, %2$s is the actual value. - return new WP_Error( 'invalid_value', sprintf( __( 'Expected %1$s, got %2$s.', 'remote-data-blocks' ), implode( ', ', $schema['enum'] ), $data ), [ 'status' => 400 ] ); - } + return $this->check_non_primitive_type( $type, $value ); + } - if ( isset( $schema['const'] ) && $data !== $schema['const'] ) { - // translators: %1$s is the expected value, %2$s is the actual value. - return new WP_Error( 'invalid_value', sprintf( __( 'Expected %1$s, got %2$s.', 'remote-data-blocks' ), $schema['const'], $data ), [ 'status' => 400 ] ); - } + /** + * Validate a non-primitive value against a schema. This method returns true + * or a WP_Error object. Never check the return value for truthiness; either + * return the value directly or check it with is_wp_error(). + * + * @param array $type The schema type to validate against. + * @param mixed $value The value to validate. + * @return bool|WP_Error Returns true if the data is valid, otherwise a WP_Error. + */ + private function check_non_primitive_type( array $type, mixed $value ): bool|WP_Error { + switch ( Types::get_type_name( $type ) ) { + case 'callable': + if ( is_callable( $value ) ) { + return true; + } - if ( isset( $schema['callback'] ) && is_callable( $schema['callback'] ) ) { - if ( false === call_user_func( $schema['callback'], $data ) ) { - // translators: %1$s is the callback name, %2$s is the value given to the callback. - return new WP_Error( 'invalid_value', sprintf( __( 'Validate callback %1$s failed with value %2$s.', 'remote-data-blocks' ), $schema['callback'], $data ), [ 'status' => 400 ] ); - } - } + return $this->create_error( 'Value must be callable', $value ); - if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { - foreach ( $schema['properties'] as $field => $field_schema ) { - if ( isset( $field_schema['required'] ) && false === $field_schema['required'] && ! isset( $data[ $field ] ) ) { - continue; + case 'const': + if ( Types::get_type_args( $type ) === $value ) { + return true; } - if ( ! isset( $data[ $field ] ) ) { - // translators: %1$s is the missing field name. - return new WP_Error( 'missing_field', sprintf( __( 'Missing field %1$s.', 'remote-data-blocks' ), $field ), [ 'status' => 400 ] ); + return $this->create_error( 'Value must be the constant', $value ); + + case 'enum': + if ( in_array( $value, Types::get_type_args( $type ), true ) ) { + return true; } - - $result = $this->validate_schema( $field_schema, $data[ $field ] ); - if ( is_wp_error( $result ) ) { - return $result; + + return $this->create_error( 'Value must be one of the enumerated values', $value ); + + case 'instance_of': + if ( is_a( $value, Types::get_type_args( $type ) ) ) { + return true; } - } - } - if ( isset( $schema['items'] ) ) { - if ( ! is_array( $data ) ) { - // translators: %1$s is the expected PHP data type, %2$s is the actual PHP data type. - return new WP_Error( 'invalid_array', sprintf( __( 'Expected %1$s, got %2$s.', 'remote-data-blocks' ), 'array', gettype( $data ) ), [ 'status' => 400 ] ); - } + return $this->create_error( 'Value must be an instance of the specified class', $value ); - foreach ( $data as $item ) { - $result = $this->validate_schema( $schema['items'], $item ); - if ( is_wp_error( $result ) ) { - return $result; + case 'list_of': + if ( ! is_array( $value ) || ! array_is_list( $value ) ) { + return $this->create_error( 'Value must be a non-associative array', $value ); } - } - } - return true; - } + $member_type = Types::get_type_args( $type ); + + foreach ( $value as $item ) { + $validated = $this->check_type( $member_type, $item ); + if ( is_wp_error( $validated ) ) { + return $this->create_error( 'Value must be a list of the specified type', $item, $validated ); + } + } + + return true; + + case 'one_of': + foreach ( Types::get_type_args( $type ) as $member_type ) { + if ( true === $this->check_type( $member_type, $value ) ) { + return true; + } + } + + return $this->create_error( 'Value must be one of the specified types', $value ); - private function check_type( mixed $value, string $expected_type ): bool { - switch ( $expected_type ) { - case 'array': - return is_array( $value ); case 'object': - return is_object( $value ) || ( is_array( $value ) && ! array_is_list( $value ) ); - case 'string': - return is_string( $value ); - case 'integer': - return is_int( $value ); + if ( ! self::check_iterable_object( $value ) ) { + return $this->create_error( 'Value must be an associative array', $value ); + } + + foreach ( Types::get_type_args( $type ) as $key => $property_type ) { + $property_value = $this->get_object_key( $value, $key ); + $validated = $this->check_type( $property_type, $property_value ); + if ( is_wp_error( $validated ) ) { + return $this->create_error( 'Object must have valid property', $key, $validated ); + } + } + + return true; + + case 'record': + if ( ! self::check_iterable_object( $value ) ) { + return $this->create_error( 'Value must be an associative array', $value ); + } + + $type_args = Types::get_type_args( $type ); + $key_type = $type_args[0]; + $value_type = $type_args[1]; + + foreach ( $value as $key => $record_value ) { + $validated = $this->check_type( $key_type, $key ); + if ( is_wp_error( $validated ) ) { + return $this->create_error( 'Record must have valid key', $key ); + } + + $validated = $this->check_type( $value_type, $record_value ); + if ( is_wp_error( $validated ) ) { + return $this->create_error( 'Record must have valid value', $record_value, $validated ); + } + } + + return true; + + case 'ref': + return $this->check_type( Types::load_ref_type( $type ), $value ); + + case 'string_matching': + $regex = Types::get_type_args( $type ); + + if ( $this->check_primitive_type( 'string', $value ) && $this->check_primitive_type( 'string', $regex ) && preg_match( $regex, $value ) ) { + return true; + } + + return $this->create_error( 'Value must match the specified regex', $value ); + + default: + return $this->create_error( 'Unknown type', Types::get_type_name( $type ) ); + } + } + + private function check_primitive_type( string $type_name, mixed $value ): bool { + switch ( $type_name ) { + case 'any': + return true; case 'boolean': return is_bool( $value ); + case 'integer': + return is_int( $value ); case 'null': return is_null( $value ); - case 'function': - return is_callable( $value ); + case 'number': + return is_numeric( $value ); + case 'string': + return is_string( $value ); + + case 'currency_in_current_locale': + return is_string( $value ) || is_numeric( $value ); + + case 'email_address': + return false !== is_email( $value ); + + case 'button_text': + case 'html': + case 'id': + case 'image_alt': + case 'markdown': + return is_string( $value ); + + case 'json_path': + return is_string( $value ) && str_starts_with( $value, '$' ); + + case 'button_url': + case 'image_url': + case 'url': + return false !== filter_var( $value, FILTER_VALIDATE_URL ); + + case 'uuid': + return wp_is_uuid( $value ); + default: return false; } } + + /* + * While an "object" in name, we expect this type to be implemented as an + * associative array since this is typically how humans represent objects in + * literal PHP code. + */ + public static function check_iterable_object( mixed $value ): bool { + return is_array( $value ) && ( ! array_is_list( $value ) || empty( $value ) ); + } + + private function create_error( string $message, mixed $value, ?WP_Error $child_error = null ): WP_Error { + $serialized_value = is_string( $value ) ? $value : wp_json_encode( $value ); + $message = sprintf( '%s: %s', esc_html( $message ), $serialized_value ); + return new WP_Error( 'invalid_type', $message, [ 'child' => $child_error ] ); + } + + private function get_object_key( mixed $data, string $key ): mixed { + return is_array( $data ) && array_key_exists( $key, $data ) ? $data[ $key ] : null; + } } diff --git a/inc/Validation/ValidatorInterface.php b/inc/Validation/ValidatorInterface.php index 8a19e49a..e6ab7217 100644 --- a/inc/Validation/ValidatorInterface.php +++ b/inc/Validation/ValidatorInterface.php @@ -10,16 +10,10 @@ * @see Validator for an implementation */ interface ValidatorInterface { - /** - * Constructor. - * - */ - public function __construct( array $schema ); - /** * Validate data against a schema. * - * + * @param mixed $data The data to validate. * @return true|\WP_Error WP_Error for invalid data, true otherwise */ public function validate( array $data ): bool|WP_Error; diff --git a/inc/WpdbStorage/DataSourceCrud.php b/inc/WpdbStorage/DataSourceCrud.php index 23b487d5..6caa02f5 100644 --- a/inc/WpdbStorage/DataSourceCrud.php +++ b/inc/WpdbStorage/DataSourceCrud.php @@ -2,154 +2,109 @@ namespace RemoteDataBlocks\WpdbStorage; -use RemoteDataBlocks\Config\ArraySerializableInterface; -use RemoteDataBlocks\Config\DataSource\HttpDataSourceInterface; +use RemoteDataBlocks\Config\DataSource\DataSourceInterface; use WP_Error; use const RemoteDataBlocks\REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP; class DataSourceCrud { - const CONFIG_OPTION_NAME = 'remote_data_blocks_config'; + const CONFIG_OPTION_NAME = 'remote_data_blocks_configs'; - public static function register_new_data_source( array $settings, ?ArraySerializableInterface $data_source = null ): HttpDataSourceInterface|WP_Error { - $data_sources = self::get_data_sources(); - - do { - $uuid = wp_generate_uuid4(); - } while ( ! empty( self::get_item_by_uuid( self::get_data_sources(), $uuid ) ) ); + public static function create_config( array $config ): array|WP_Error { + return self::save_config( $config ); + } - $new_data_source = $data_source ?? self::resolve_data_source( array_merge( $settings, [ 'uuid' => $uuid ] ) ); + public static function delete_config_by_uuid( string $uuid ): bool|WP_Error { + $configs = array_values( array_filter( self::get_configs(), function ( $config ) use ( $uuid ) { + return $config['uuid'] !== $uuid; + } ) ); - if ( is_wp_error( $new_data_source ) ) { - return $new_data_source; + if ( true !== self::save_configs( $configs ) ) { + return new WP_Error( 'failed_to_delete_data_source', __( 'Failed to delete data source', 'remote-data-blocks' ) ); } - $result = self::save_data_source( $new_data_source, $data_sources ); + return true; + } - if ( true !== $result ) { - return new WP_Error( 'failed_to_register_data_source', __( 'Failed to register data source.', 'remote-data-blocks' ) ); + public static function get_config_by_uuid( string $uuid ): array|WP_Error { + foreach ( self::get_configs() as $config ) { + if ( $config['uuid'] === $uuid ) { + return $config; + } } - return $new_data_source; + return new WP_Error( 'data_source_not_found', __( 'Data source not found', 'remote-data-blocks' ), [ 'status' => 404 ] ); } - public static function get_config(): array { - return get_option( self::CONFIG_OPTION_NAME, [] ); + public static function get_configs(): array { + return self::get_all_configs(); } - public static function get_data_sources( string $service = '' ): array { - $data_sources = self::get_config(); + public static function get_configs_by_service( string $service_name ): array { + return array_values( array_filter( self::get_configs(), function ( $config ) use ( $service_name ): bool { + return $config['service'] === $service_name; + } ) ); + } - if ( $service ) { - return array_values( array_filter( $data_sources, function ( $config ) use ( $service ) { - return $config['service'] === $service; - } ) ); + public static function update_config_by_uuid( string $uuid, array $service_config ): array|WP_Error { + $config = self::get_config_by_uuid( $uuid ); + + if ( is_wp_error( $config ) ) { + return $config; } - return $data_sources; - } + // Merge the new service config with the existing one. + $config['service_config'] = array_merge( $config['service_config'] ?? [], $service_config ); - /** - * Get the array list of data sources - */ - public static function get_data_sources_list(): array { - return array_values( self::get_data_sources() ); + return self::save_config( $config ); } - public static function get_item_by_uuid( array $data_sources, string $uuid ): array|false { - return $data_sources[ $uuid ] ?? false; + private static function get_all_configs(): array { + return get_option( self::CONFIG_OPTION_NAME, [] ); } - public static function update_item_by_uuid( string $uuid, array $new_item ): HttpDataSourceInterface|WP_Error { - $data_sources = self::get_data_sources(); - $item = self::get_item_by_uuid( $data_sources, $uuid ); - - if ( ! $item ) { - return new WP_Error( 'data_source_not_found', __( 'Data source not found.', 'remote-data-blocks' ), [ 'status' => 404 ] ); - } - - // Check if new UUID is provided - $new_uuid = $new_item['uuid'] ?? null; - if ( $new_uuid && $new_uuid !== $uuid ) { - // Ensure the new UUID doesn't already exist - if ( self::get_item_by_uuid( $data_sources, $new_uuid ) ) { - return new WP_Error( 'uuid_conflict', __( 'The new UUID already exists.', 'remote-data-blocks' ), [ 'status' => 409 ] ); - } - - // Remove the old item from data source array if UUID is being updated - unset( $data_sources[ $uuid ] ); - } - - // Merge new item properties - $merged_item = array_merge( $item, $new_item ); - - // Resolve and save the updated item - $resolved_data_source = self::resolve_data_source( $merged_item ); - if ( is_wp_error( $resolved_data_source ) ) { - return $resolved_data_source; // If resolving fails, return error + private static function inflate_config( array $config ): DataSourceInterface|WP_Error { + $data_source_class = REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP[ $config['service'] ] ?? null; + if ( null === $data_source_class ) { + return new WP_Error( 'unsupported_data_source', __( 'Unsupported data source service', 'remote-data-blocks' ) ); } - // Save the updated item - $result = self::save_data_source( $resolved_data_source, $data_sources, $uuid ); // Passing old UUID to remove it if changed - if ( !$result ) { - return new WP_Error( 'failed_to_update_data_source', __( 'Failed to update data source.', 'remote-data-blocks' ) ); - } - - return $resolved_data_source; - } - - - public static function delete_item_by_uuid( string $uuid ): WP_Error|bool { - $data_sources = self::get_data_sources(); - unset( $data_sources[ $uuid ] ); - $result = update_option( self::CONFIG_OPTION_NAME, $data_sources ); - if ( true !== $result ) { - return new WP_Error( 'failed_to_delete_data_source', __( 'Failed to delete data source.', 'remote-data-blocks' ) ); - } - return true; + return $data_source_class::from_array( $config ); } - public static function get_by_uuid( string $uuid ): array|false { - $data_sources = self::get_data_sources(); - foreach ( $data_sources as $source ) { - if ( $source['uuid'] === $uuid ) { - return $source; - } + private static function save_config( array $config ): array|WP_Error { + // Update metadata. + $now = gmdate( 'Y-m-d H:i:s' ); + $new_config = [ + '__metadata' => [ + 'created_at' => $config['__metadata']['created_at'] ?? $now, + 'updated_at' => $now, + ], + 'service' => $config['service'] ?? 'unknown', + 'service_config' => $config['service_config'] ?? [], + 'uuid' => $config['uuid'] ?? wp_generate_uuid4(), + ]; + + // Validate the data source by attempting to instantiate it. + $instance = self::inflate_config( $new_config ); + if ( is_wp_error( $instance ) ) { + return $instance; } - return false; - } - private static function save_data_source( ArraySerializableInterface $data_source, array $data_source_configs, ?string $original_uuid = null ): bool { - $config = $data_source->to_array(); - - if ( ! isset( $config['__metadata'] ) ) { - $config['__metadata'] = []; - } + // Create or update the data source. + $configs = array_values( array_filter( self::get_configs(), function ( $existing ) use ( $new_config ) { + return $existing['uuid'] !== $new_config['uuid']; + } ) ); + $configs[] = $new_config; - $now = gmdate( 'Y-m-d H:i:s' ); - $config['__metadata']['updated_at'] = $now; - - if ( ! isset( $config['__metadata']['created_at'] ) ) { - $config['__metadata']['created_at'] = $now; - } - - // If the UUID has changed, remove the old entry based on the original UUID - if ( $original_uuid && $original_uuid !== $config['uuid'] ) { - unset( $data_source_configs[ $original_uuid ] ); // Remove old item if UUID is changing + if ( true !== self::save_configs( $configs ) ) { + return new WP_Error( 'failed_to_save_data_source', __( 'Failed to save data source', 'remote-data-blocks' ) ); } - - // Add or update the data source with the new UUID - $data_source_configs[ $config['uuid'] ] = $config; - - // Save updated configuration - return update_option( self::CONFIG_OPTION_NAME, $data_source_configs ); - } - private static function resolve_data_source( array $config ): HttpDataSourceInterface|WP_Error { - if ( isset( REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP[ $config['service'] ] ) ) { - return REMOTE_DATA_BLOCKS__DATA_SOURCE_CLASSMAP[ $config['service'] ]::from_array( $config ); - } + return $new_config; + } - return new WP_Error( 'unsupported_data_source', __( 'DataSource class not found.', 'remote-data-blocks' ) ); + private static function save_configs( array $configs ): bool|WP_Error { + return update_option( self::CONFIG_OPTION_NAME, $configs ); } } diff --git a/src/blocks/remote-data-container/components/BlockBindingControls.tsx b/src/blocks/remote-data-container/components/BlockBindingControls.tsx index d7f4936e..ef655000 100644 --- a/src/blocks/remote-data-container/components/BlockBindingControls.tsx +++ b/src/blocks/remote-data-container/components/BlockBindingControls.tsx @@ -1,6 +1,12 @@ import { CheckboxControl, SelectControl } from '@wordpress/components'; -import { TEXT_FIELD_TYPES } from '@/blocks/remote-data-container/config/constants'; +import { + BUTTON_TEXT_FIELD_TYPES, + BUTTON_URL_FIELD_TYPES, + IMAGE_ALT_FIELD_TYPES, + IMAGE_URL_FIELD_TYPES, + TEXT_FIELD_TYPES, +} from '@/blocks/remote-data-container/config/constants'; import { sendTracksEvent } from '@/blocks/remote-data-container/utils/tracks'; import { getBlockDataSourceType } from '@/utils/localized-block-data'; @@ -118,7 +124,7 @@ export function BlockBindingControls( props: BlockBindingControlsProps ) { <> o.type === override.type && o.target === override.target ); + return overrides.findIndex( + o => o.sourceType === override.sourceType && o.source === override.source + ); } function updateOverrides( inputVar: string, index: number ) { - const overrides = availableOverrides[ inputVar ]?.overrides[ index ]; + const overrides = availableOverrides[ inputVar ]?.[ index ]; const copyOfQueryInputOverrides = { ...remoteData.queryInputOverrides }; if ( ! overrides || index === -1 ) { @@ -48,8 +50,8 @@ export function OverridesPanel( props: OverridesPanelProps ) { } ); sendTracksEvent( 'remotedatablocks_remote_data_container_override', { data_source_type: getBlockDataSourceType( remoteData.blockName ), - override_type: overrides?.type, - override_target: overrides?.target, + override_type: overrides?.sourceType, + override_target: overrides?.source, } ); } @@ -64,10 +66,10 @@ export function OverridesPanel( props: OverridesPanelProps ) { { Object.entries( availableOverrides ).map( ( [ key, value ] ) => ( ( { + ...value.map( ( override, index ) => ( { label: override.display, value: index.toString(), } ) ), diff --git a/src/blocks/remote-data-container/config/constants.ts b/src/blocks/remote-data-container/config/constants.ts index e2383378..9106ecdf 100644 --- a/src/blocks/remote-data-container/config/constants.ts +++ b/src/blocks/remote-data-container/config/constants.ts @@ -8,12 +8,22 @@ export const SUPPORTED_CORE_BLOCKS = [ 'core/paragraph', ]; -export const DISPLAY_QUERY_KEY = '__DISPLAY__'; +export const DISPLAY_QUERY_KEY = 'display'; export const REMOTE_DATA_CONTEXT_KEY = 'remote-data-blocks/remoteData'; export const REMOTE_DATA_REST_API_URL = getRestUrl(); export const CONTAINER_CLASS_NAME = getClassName( 'container' ); -export const IMAGE_FIELD_TYPES = [ 'image_alt', 'image_url' ]; -export const TEXT_FIELD_TYPES = [ 'number', 'base64', 'currency', 'string', 'markdown' ]; -export const BUTTON_FIELD_TYPES = [ 'button_url' ]; +export const BUTTON_TEXT_FIELD_TYPES = [ 'button_text' ]; +export const BUTTON_URL_FIELD_TYPES = [ 'button_url' ]; +export const IMAGE_ALT_FIELD_TYPES = [ 'image_alt' ]; +export const IMAGE_URL_FIELD_TYPES = [ 'image_url' ]; +export const TEXT_FIELD_TYPES = [ + 'currency_in_current_locale', + 'email_address', + 'html', + 'integer', + 'markdown', + 'number', + 'string', +]; diff --git a/src/data-sources/DataSourceList.tsx b/src/data-sources/DataSourceList.tsx index fa80c322..46d6e980 100644 --- a/src/data-sources/DataSourceList.tsx +++ b/src/data-sources/DataSourceList.tsx @@ -4,8 +4,14 @@ import { Placeholder, } from '@wordpress/components'; import { useDispatch } from '@wordpress/data'; -import { DataViews, filterSortAndPaginate, type View } from '@wordpress/dataviews/wp'; -import { useMemo, useState } from '@wordpress/element'; +import { + Action, + DataViews, + Field, + filterSortAndPaginate, + type View, +} from '@wordpress/dataviews/wp'; +import { useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { chevronRightSmall, info } from '@wordpress/icons'; import { store as noticesStore, NoticeStoreActions, WPNotice } from '@wordpress/notices'; @@ -50,23 +56,23 @@ const DataSourceList = () => { }; const renderDataSourceMeta = ( source: DataSourceConfig ) => { - const tags = []; + const tags: { key: string; primaryValue: string; secondaryValue?: string }[] = []; switch ( source.service ) { case 'airtable': tags.push( { key: 'base', - primaryValue: source.base?.name, - secondaryValue: source.tables?.[ 0 ]?.name, + primaryValue: source.service_config.base?.name, + secondaryValue: source.service_config.tables?.[ 0 ]?.name, } ); break; case 'shopify': - tags.push( { key: 'store', primaryValue: source.store_name } ); + tags.push( { key: 'store', primaryValue: source.service_config.store_name } ); break; case 'google-sheets': tags.push( { key: 'spreadsheet', - primaryValue: source.spreadsheet.name, - secondaryValue: source.sheet.name, + primaryValue: source.service_config.spreadsheet.name ?? 'Google Sheet', + secondaryValue: source.service_config.sheet.name, } ); break; } @@ -89,7 +95,7 @@ const DataSourceList = () => { const getServiceLabel = ( service: ( typeof SUPPORTED_SERVICES )[ number ] ) => { // eslint-disable-next-line security/detect-object-injection - return SUPPORTED_SERVICES_LABELS[ service ]; + return SUPPORTED_SERVICES_LABELS[ service ] ?? 'HTTP'; }; function showSnackbar( type: 'success' | 'error', message: string ): void { @@ -106,7 +112,10 @@ const DataSourceList = () => { break; } } - const getServiceIcon = ( service: ( typeof SUPPORTED_SERVICES )[ number ] ) => { + + const getServiceIcon = ( + service: ( typeof SUPPORTED_SERVICES )[ number ] + ): React.ReactElement => { switch ( service ) { case 'airtable': return AirtableIcon; @@ -114,10 +123,8 @@ const DataSourceList = () => { return ShopifyIcon; case 'google-sheets': return GoogleSheetsIcon; - case 'generic-http': - return HttpIcon; default: - return null; + return HttpIcon; } }; @@ -131,75 +138,69 @@ const DataSourceList = () => { layout: {}, } ); - const fields = useMemo( - () => [ - { - id: 'display_name', - label: __( 'Source', 'remote-data-blocks' ), - enableGlobalSearch: true, - render: ( { item }: { item: DataSourceConfig } ) => { - const serviceIcon = getServiceIcon( item.service ); - return ( - <> - { serviceIcon && ( - - ) } - { item.display_name } - - ); - }, - }, - { - id: 'service', - label: __( 'Service', 'remote-data-blocks' ), - enableGlobalSearch: true, - elements: SUPPORTED_SERVICES.map( service => ( { - value: service, - label: getServiceLabel( service ), - } ) ), - }, - { - id: 'meta', - label: __( 'Meta', 'remote-data-blocks' ), - enableGlobalSearch: true, - render: ( { item }: { item: DataSourceConfig } ) => renderDataSourceMeta( item ), + const fields: Field< DataSourceConfig >[] = [ + { + id: 'display_name', + label: __( 'Source', 'remote-data-blocks' ), + enableGlobalSearch: true, + render: ( { item }: { item: DataSourceConfig } ) => { + return ( + <> + + { item.service_config.display_name } + + ); }, - ], - [] - ); + }, + { + id: 'service', + label: __( 'Service', 'remote-data-blocks' ), + enableGlobalSearch: true, + elements: SUPPORTED_SERVICES.map( service => ( { + value: service, + label: getServiceLabel( service ), + } ) ), + }, + { + id: 'meta', + label: __( 'Meta', 'remote-data-blocks' ), + enableGlobalSearch: true, + render: ( { item }: { item: DataSourceConfig } ) => renderDataSourceMeta( item ), + }, + ]; // filter, sort and paginate data - const { data: shownData, paginationInfo } = useMemo( () => { - return filterSortAndPaginate( dataSources, view, fields ); - }, [ dataSources, fields, view ] ); + const { data: shownData, paginationInfo } = filterSortAndPaginate( dataSources, view, fields ); const defaultLayouts = { table: {}, }; - const actions = [ + const actions: Action< DataSourceConfig >[] = [ { id: 'edit', label: __( 'Edit', 'remote-data-blocks' ), icon: 'edit', isPrimary: true, + isEligible: ( item: DataSourceConfig ) => { + return Boolean( item?.uuid ); + }, callback: ( [ item ]: DataSourceConfig[] ) => { - if ( item ) { + if ( item?.uuid ) { onEditDataSource( item.uuid ); } }, - isEligible: ( item: DataSourceConfig ) => { - return item ? SUPPORTED_SERVICES.includes( item.service ) : false; - }, }, { id: 'copy', label: __( 'Copy UUID', 'remote-data-blocks' ), icon: 'copy', - isPrimary: true, + isEligible: ( item: DataSourceConfig ) => { + return Boolean( item?.uuid ); + }, callback: ( [ item ]: DataSourceConfig[] ) => { if ( item && item.uuid ) { navigator.clipboard @@ -221,6 +222,9 @@ const DataSourceList = () => { label: __( 'Delete', 'remote-data-blocks' ), icon: 'trash', isDestructive: true, + isEligible: ( item: DataSourceConfig ) => { + return Boolean( item?.uuid ); + }, callback: ( items: DataSourceConfig[] ) => { if ( items.length === 1 ) { if ( items[ 0 ] ) { @@ -230,9 +234,6 @@ const DataSourceList = () => { onDeleteDataSource( items ); } }, - isEligible: ( item: DataSourceConfig ) => { - return item ? SUPPORTED_SERVICES.includes( item.service ) : false; - }, supportsBulk: true, }, ]; @@ -260,7 +261,7 @@ const DataSourceList = () => { onChangeView={ setView } paginationInfo={ paginationInfo } defaultLayouts={ defaultLayouts } - getItemId={ ( item: DataSourceConfig ) => item.uuid } + getItemId={ ( item: DataSourceConfig ) => item.uuid ?? `not-persisted-${ Math.random() }` } isLoading={ loadingDataSources } /> { dataSourceToDelete && ( @@ -279,7 +280,7 @@ const DataSourceList = () => { : sprintf( __( 'Are you sure you want to delete %s data source "%s"?', 'remote-data-blocks' ), getServiceLabel( dataSourceToDelete.service ), - dataSourceToDelete.display_name + dataSourceToDelete.service_config.display_name ) } ) } diff --git a/src/data-sources/airtable/AirtableSettings.tsx b/src/data-sources/airtable/AirtableSettings.tsx index 39743e39..cecc5c01 100644 --- a/src/data-sources/airtable/AirtableSettings.tsx +++ b/src/data-sources/airtable/AirtableSettings.tsx @@ -1,12 +1,11 @@ import { SelectControl, Spinner } from '@wordpress/components'; import { InputChangeCallback } from '@wordpress/components/build-types/input-control/types'; -import { useEffect, useMemo, useState } from '@wordpress/element'; +import { useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { ChangeEvent } from 'react'; import { CustomFormFieldToken } from '../components/CustomFormFieldToken'; import { SUPPORTED_AIRTABLE_TYPES } from '@/data-sources/airtable/constants'; -import { AirtableFormState } from '@/data-sources/airtable/types'; import { getAirtableOutputQueryMappingValue } from '@/data-sources/airtable/utils'; import { DataSourceForm } from '@/data-sources/components/DataSourceForm'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; @@ -19,51 +18,15 @@ import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { AirtableConfig, AirtableOutputQueryMappingValue, + AirtableServiceConfig, SettingsComponentProps, } from '@/data-sources/types'; import { getConnectionMessage } from '@/data-sources/utils'; import { useForm } from '@/hooks/useForm'; -import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import { AirtableIcon, AirtableIconWithText } from '@/settings/icons/AirtableIcon'; -import { StringIdName } from '@/types/common'; import { SelectOption } from '@/types/input'; -const initialState: AirtableFormState = { - access_token: '', - base: null, - display_name: '', - table: null, - table_fields: new Set< string >(), -}; - -const getInitialStateFromConfig = ( config?: AirtableConfig ): AirtableFormState => { - if ( ! config ) { - return initialState; - } - const initialStateFromConfig: AirtableFormState = { - access_token: config.access_token, - base: config.base, - display_name: config.display_name, - table: null, - table_fields: new Set< string >(), - }; - - if ( Array.isArray( config.tables ) ) { - const [ table ] = config.tables; - - if ( table ) { - initialStateFromConfig.table = { - id: table.id, - name: table.name, - }; - initialStateFromConfig.table_fields = new Set( - table.output_query_mappings.map( ( { key } ) => key ) - ); - } - } - - return initialStateFromConfig; -}; +const SERVICE_CONFIG_VERSION = 1; const defaultSelectBaseOption: SelectOption = { disabled: true, @@ -77,79 +40,67 @@ const defaultSelectTableOption: SelectOption = { value: '', }; +// eslint-disable-next-line complexity export const AirtableSettings = ( { mode, - uuid: uuidFromProps, + uuid, config, }: SettingsComponentProps< AirtableConfig > ) => { - const { goToMainScreen } = useSettingsContext(); - const { updateDataSource, addDataSource } = useDataSources( false ); + const { onSave } = useDataSources< AirtableConfig >( false ); - const { state, handleOnChange } = useForm< AirtableFormState >( { - initialValues: getInitialStateFromConfig( config ), + const { state, handleOnChange, validState } = useForm< AirtableServiceConfig >( { + initialValues: config?.service_config ?? { __version: SERVICE_CONFIG_VERSION }, } ); - const [ baseOptions, setBaseOptions ] = useState< SelectOption[] >( [ defaultSelectBaseOption ] ); - const [ tableOptions, setTableOptions ] = useState< SelectOption[] >( [ - defaultSelectTableOption, - ] ); - const [ availableTableFields, setAvailableTableFields ] = useState< string[] >( [] ); - const { fetchingUserId, userId, userIdError } = useAirtableApiUserId( state.access_token ); + const [ currentTableId, setCurrentTableId ] = useState< string | null >( + state.tables?.[ 0 ]?.id ?? null + ); + const [ tableFields, setTableFields ] = useState< string[] >( + state.tables?.[ 0 ]?.output_query_mappings.map( mapping => mapping.key ).filter( Boolean ) ?? [] + ); + const { fetchingUserId, userId, userIdError } = useAirtableApiUserId( state.access_token ?? '' ); const { bases, basesError, fetchingBases } = useAirtableApiBases( - state.access_token, + state.access_token ?? '', userId ?? '' ); const { fetchingTables, tables, tablesError } = useAirtableApiTables( - state.access_token, + state.access_token ?? '', state.base?.id ?? '' ); - const [ newUUID, setNewUUID ] = useState< string | null >( uuidFromProps ?? null ); + const selectedTable = tables?.find( table => table.id === currentTableId ) ?? null; + const availableTableFields: string[] = + selectedTable?.fields + .filter( field => SUPPORTED_AIRTABLE_TYPES.includes( field.type ) ) + .map( field => field.name ) ?? []; + + const baseOptions = [ + { + ...defaultSelectBaseOption, + label: __( 'Select a base', 'remote-data-blocks' ), + }, + ...( bases ?? [] ).map( ( { name, id } ) => ( { label: name, value: id } ) ), + ]; + const tableOptions = [ + { + ...defaultSelectTableOption, + label: __( 'Select a table', 'remote-data-blocks' ), + }, + ...( tables ?? [] ).map( ( { name, id } ) => ( { label: name, value: id, disabled: false } ) ), + ]; const onSaveClick = async () => { - if ( ! state.base || ! state.table ) { - // TODO: Error handling - return; - } - - const selectedTable = tables?.find( table => table.id === state.table?.id ); - if ( ! selectedTable ) { + if ( ! validState || ! selectedTable ) { return; } const airtableConfig: AirtableConfig = { - uuid: uuidFromProps ?? '', - newUUID: newUUID ?? '', service: 'airtable', - access_token: state.access_token, - base: state.base, - display_name: state.display_name, - tables: [ - { - id: state.table.id, - name: state.table.name, - output_query_mappings: Array.from( state.table_fields ) - .map( key => { - const field = selectedTable.fields.find( tableField => tableField.name === key ); - if ( field ) { - return getAirtableOutputQueryMappingValue( field ); - } - /** - * Remove any fields which are not from this table or not supported. - */ - return null; - } ) - .filter( Boolean ) as AirtableOutputQueryMappingValue[], - }, - ], + service_config: validState, + uuid: uuid ?? null, }; - if ( mode === 'add' ) { - await addDataSource( airtableConfig ); - } else { - await updateDataSource( airtableConfig ); - } - goToMainScreen(); + return onSave( airtableConfig, mode ); }; const onTokenInputChange: InputChangeCallback = ( token: string | undefined ) => { @@ -162,161 +113,100 @@ export const AirtableSettings = ( { ) => { if ( extra?.event ) { const { id } = extra.event.target; - let newValue: StringIdName | null = null; if ( id === 'base' ) { const selectedBase = bases?.find( base => base.id === value ); - newValue = { id: value, name: selectedBase?.name ?? '' }; - } else if ( id === 'table' ) { - const selectedTable = tables?.find( table => table.id === value ); - newValue = { id: value, name: selectedTable?.name ?? '' }; - - if ( value !== state.table?.id ) { - // Reset the selected fields when the table changes. - handleOnChange( 'table_fields', new Set< string >() ); - } - } - handleOnChange( id, newValue ); - } - }; - - const connectionMessage = useMemo( () => { - if ( fetchingUserId ) { - return __( 'Validating connection...', 'remote-data-blocks' ); - } else if ( userIdError ) { - return getConnectionMessage( - 'error', - __( 'Connection failed. Please verify your access token.', 'remote-data-blocks' ) - ); - } else if ( userId ) { - return getConnectionMessage( - 'success', - __( 'Connection successful.', 'remote-data-blocks' ) - ); - } - return ( - - - { __( 'How do I get my token?', 'remote-data-blocks' ) } - - - ); - }, [ fetchingUserId, userId, userIdError ] ); - - const shouldAllowContinue = useMemo( () => { - return userId !== null; - }, [ userId ] ); - - const shouldAllowSubmit = useMemo( () => { - return bases !== null && tables !== null && Boolean( state.base ) && Boolean( state.table ); - }, [ bases, tables, state.base, state.table ] ); - - const basesHelpText = useMemo( () => { - if ( userId ) { - if ( basesError ) { - return __( - 'Failed to fetch bases. Please check that your access token has the `schema.bases:read` Scope.' - ); - } else if ( fetchingBases ) { - return __( 'Fetching bases...' ); - } else if ( bases?.length === 0 ) { - return __( 'No bases found.' ); + handleOnChange( 'base', { id: value, name: selectedBase?.name ?? '' } ); + return; } - } - return 'Select a base from which to fetch data.'; - }, [ bases, basesError, fetchingBases, userId ] ); - - const tablesHelpText = useMemo( () => { - if ( bases?.length && state.base ) { - if ( tablesError ) { - return __( - 'Failed to fetch tables. Please check that your access token has the `schema.tables:read` Scope.', - 'remote-data-blocks' - ); - } else if ( fetchingTables ) { - return __( 'Fetching tables...', 'remote-data-blocks' ); - } else if ( tables ) { - if ( state.table ) { - const selectedTable = tables.find( table => table.id === state.table?.id ); - - if ( selectedTable ) { - return sprintf( - __( '%s fields found', 'remote-data-blocks' ), - selectedTable.fields.length - ); - } + if ( id === 'tables' ) { + if ( selectedTable ) { + return; } - if ( ! tables.length ) { - return __( 'No tables found', 'remote-data-blocks' ); - } + handleOnChange( 'tables', [] ); + return; } - return __( 'Select a table from which to fetch data.', 'remote-data-blocks' ); + handleOnChange( id, value ); } + }; - return 'Select a table to attach with this data source.'; - }, [ bases, fetchingTables, state.base, state.table, tables, tablesError ] ); - - useEffect( () => { - if ( ! bases?.length ) { - return; - } + let connectionMessage: React.ReactNode = ( + + + { __( 'How do I get my token?', 'remote-data-blocks' ) } + + + ); - setBaseOptions( [ - { - ...defaultSelectBaseOption, - label: __( 'Select a base', 'remote-data-blocks' ), - }, - ...( bases ?? [] ).map( ( { name, id } ) => ( { label: name, value: id } ) ), - ] ); - }, [ bases ] ); + if ( fetchingUserId ) { + connectionMessage = __( 'Validating connection...', 'remote-data-blocks' ); + } else if ( userIdError ) { + connectionMessage = getConnectionMessage( + 'error', + __( 'Connection failed. Please verify your access token.', 'remote-data-blocks' ) + ); + } else if ( userId ) { + connectionMessage = getConnectionMessage( + 'success', + __( 'Connection successful.', 'remote-data-blocks' ) + ); + } - useEffect( () => { - if ( ! state?.base ) { - return; - } + const shouldAllowContinue = userId !== null; + const shouldAllowSubmit = + bases !== null && tables !== null && Boolean( state.base ) && Boolean( selectedTable ); - if ( tables ) { - setTableOptions( [ - { - ...defaultSelectBaseOption, - label: __( 'Select a table', 'remote-data-blocks' ), - }, - ...tables.map( ( { name, id } ) => ( { label: name, value: id, disabled: false } ) ), - ] ); + let basesHelpText: React.ReactNode = 'Select a base from which to fetch data.'; + if ( userId ) { + if ( basesError ) { + basesHelpText = __( + 'Failed to fetch bases. Please check that your access token has the `schema.bases:read` Scope.' + ); + } else if ( fetchingBases ) { + basesHelpText = __( 'Fetching bases...' ); + } else if ( bases?.length === 0 ) { + basesHelpText = __( 'No bases found.' ); } - }, [ state.base, tables ] ); - - useEffect( () => { - const newAvailableTableFields: string[] = []; - - if ( state.table && tables ) { - const selectedTable = tables.find( table => table.id === state.table?.id ); + } + let tablesHelpText: string = __( + 'Select a table to attach with this data source.', + 'remote-data-blocks' + ); + if ( bases?.length && state.base ) { + if ( tablesError ) { + tablesHelpText = __( + 'Failed to fetch tables. Please check that your access token has the `schema.tables:read` Scope.', + 'remote-data-blocks' + ); + } else if ( fetchingTables ) { + tablesHelpText = __( 'Fetching tables...', 'remote-data-blocks' ); + } else if ( tables ) { if ( selectedTable ) { - selectedTable.fields.forEach( field => { - if ( SUPPORTED_AIRTABLE_TYPES.includes( field.type ) ) { - newAvailableTableFields.push( field.name ); - } - } ); + tablesHelpText = sprintf( + __( '%s fields found', 'remote-data-blocks' ), + selectedTable.fields.length + ); + } + + if ( ! tables.length ) { + tablesHelpText = __( 'No tables found', 'remote-data-blocks' ); } } - setAvailableTableFields( newAvailableTableFields ); - }, [ state.table, tables ] ); + tablesHelpText = __( 'Select a table from which to fetch data.', 'remote-data-blocks' ); + } return ( <> - { state.table && availableTableFields.length ? ( + { selectedTable && availableTableFields.length ? ( { + let newTableFields: string[]; if ( selection.includes( 'Select All' ) ) { - handleOnChange( 'table_fields', new Set( availableTableFields ) ); + newTableFields = Array.from( new Set( availableTableFields ) ); } else if ( selection.includes( 'Deselect All' ) ) { - handleOnChange( 'table_fields', new Set() ); + newTableFields = []; } else { - handleOnChange( - 'table_fields', + newTableFields = Array.from( new Set( - selection.filter( item => item !== 'Select All' && item !== 'Deselect All' ) + selection + .filter( item => item !== 'Select All' && item !== 'Deselect All' ) + .map( item => ( 'object' === typeof item ? item.value : item ) ) ) ); } + setTableFields( newTableFields ); + handleOnChange( 'tables', [ + { + id: selectedTable.id, + name: selectedTable.name, + output_query_mappings: newTableFields + .map( key => { + const field = selectedTable.fields.find( + tableField => tableField.name === key + ); + if ( field ) { + return getAirtableOutputQueryMappingValue( field ); + } + /** + * Remove any fields which are not from this table or not supported. + */ + return null; + } ) + .filter( Boolean ) as AirtableOutputQueryMappingValue[], + }, + ] ); } } suggestions={ [ - ...( state.table_fields.size === availableTableFields.length + ...( tableFields.length === availableTableFields.length ? [ 'Deselect All' ] : [ 'Select All' ] ), ...availableTableFields, ] } - value={ Array.from( state.table_fields ) } + value={ tableFields } __experimentalValidateInput={ input => availableTableFields.includes( input ) || input === 'Select All' || @@ -384,7 +297,7 @@ export const AirtableSettings = ( { __next40pxDefaultSize /> ) : ( - state.table && + selectedTable && ) } diff --git a/src/data-sources/airtable/types.ts b/src/data-sources/airtable/types.ts index 088d3259..d8a5c80a 100644 --- a/src/data-sources/airtable/types.ts +++ b/src/data-sources/airtable/types.ts @@ -1,5 +1,3 @@ -import { StringIdName } from '@/types/common'; - export interface AirtableBase { id: string; name: string; @@ -15,14 +13,6 @@ export interface AirtableBaseSchema { tables: AirtableTable[]; } -export type AirtableFormState = { - access_token: string; - base: StringIdName | null; - display_name: string; - table: StringIdName | null; - table_fields: Set< string >; -}; - export interface AirtableTable { id: string; name: string; diff --git a/src/data-sources/airtable/utils.ts b/src/data-sources/airtable/utils.ts index bd9ee097..ffdf9b81 100644 --- a/src/data-sources/airtable/utils.ts +++ b/src/data-sources/airtable/utils.ts @@ -34,8 +34,11 @@ export const getAirtableOutputQueryMappingValue = ( case 'currency': return { ...baseField, - type: 'currency', - prefix: field.options?.symbol, + type: 'currency_in_current_locale', + // Symbol is not enough information for proper currency formatting that + // respects the user's locale. We will table proper formatting until + // we understand use cases better. + // prefix: field.options?.symbol, }; case 'checkbox': diff --git a/src/data-sources/components/DataSourceForm.tsx b/src/data-sources/components/DataSourceForm.tsx index a1f1cd7d..8b067dc6 100644 --- a/src/data-sources/components/DataSourceForm.tsx +++ b/src/data-sources/components/DataSourceForm.tsx @@ -6,16 +6,12 @@ import { VisuallyHidden, __experimentalInputControl as InputControl, __experimentalInputControlPrefixWrapper as InputControlPrefixWrapper, - __experimentalSpacer as Spacer, } from '@wordpress/components'; -import { Children, createPortal, isValidElement, useEffect, useState } from '@wordpress/element'; -import { __, sprintf } from '@wordpress/i18n'; +import { Children, createPortal, isValidElement, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { lockSmall } from '@wordpress/icons'; import { DataSourceFormActions } from './DataSourceFormActions'; -import { useDataSources } from '../hooks/useDataSources'; -import { ModalWithButtonTrigger } from '@/blocks/remote-data-container/components/modals/BaseModal'; -import { useModalState } from '@/blocks/remote-data-container/hooks/useModalState'; import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; interface DataSourceFormProps { @@ -44,8 +40,8 @@ interface DataSourceFormSetupProps { verticalAlign?: string; }; inputIcon: IconType; - newUUID: string | null; - setNewUUID: ( uuid: string | null ) => void; + newUUID?: string | null; + setNewUUID?: ( uuid: string | null ) => void; uuidFromProps?: string; } @@ -69,8 +65,6 @@ const DataSourceForm = ( { children, onSave }: DataSourceFormProps ) => { const [ currentStep, setCurrentStep ] = useState( 1 ); const { goToMainScreen, screen } = useSettingsContext(); - const { checkDisplayNameConflict } = useDataSources(); - const steps = Children.toArray( children ); const singleStep = steps.length === 1 || screen === 'editDataSource'; @@ -81,14 +75,6 @@ const DataSourceForm = ( { children, onSave }: DataSourceFormProps ) => { if ( isValidElement< { canProceed?: boolean; displayName: string; uuidFromProps: string } >( step ) ) { - const { displayName, uuidFromProps } = step.props; - - if ( currentStep === 1 ) { - return ( - Boolean( step.props?.canProceed ) && - checkDisplayNameConflict( displayName, uuidFromProps ) - ); - } return Boolean( step.props?.canProceed ); } return false; @@ -204,18 +190,12 @@ const DataSourceForm = ( { children, onSave }: DataSourceFormProps ) => { const DataSourceFormSetup = ( { children, - canProceed, displayName: initialDisplayName, handleOnChange, heading, inputIcon, - newUUID, - setNewUUID, - uuidFromProps, }: DataSourceFormSetupProps ) => { - const { close, isOpen, open } = useModalState(); const { screen, service } = useSettingsContext(); - const { checkDisplayNameConflict } = useDataSources(); const [ displayName, setDisplayName ] = useState( initialDisplayName ); const [ errors, setErrors ] = useState< Record< string, string > >( {} ); @@ -232,39 +212,16 @@ const DataSourceFormSetup = ( { handleOnChange( 'display_name', sanitizedDisplayName ?? '' ); }; - const isValidUUIDv4 = ( uuid: string ) => { - const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; - return uuidv4Regex.test( uuid ); - }; - const validateDisplayName = () => { - const hasConflict = ! checkDisplayNameConflict( displayName, uuidFromProps ?? '' ); - if ( ! displayName.trim() ) { setErrors( { displayName: __( 'Please provide a name for your data source.', 'remote-data-blocks' ), } ); - } else if ( hasConflict ) { - setErrors( { - displayName: sprintf( - __( - 'Data source "%s" already exists. Please choose another name.', - 'remote-data-blocks' - ), - displayName - ), - } ); } else { setErrors( {} ); } }; - useEffect( () => { - if ( canProceed ) { - validateDisplayName(); - } - }, [ canProceed, displayName ] ); - return ( - - { errors.displayName - ? errors.displayName - : __( 'Only visible to you and other site managers. ', 'remote-data-blocks' ) } - - { screen === 'editDataSource' && ! errors.displayName && ( - -
{ - event.preventDefault(); - if ( newUUID && isValidUUIDv4( newUUID ) ) { - isValidUUIDv4( newUUID ); - handleOnChange( 'uuid', newUUID ?? '' ); - setErrors( {} ); - close(); - } else { - setErrors( { - uuid: __( - 'Please use valid UUIDv4 formatting to save changes.', - 'remote-data-blocks' - ), - } ); - } - } } - > - setNewUUID( uuid ?? null ) } - placeholcomponents-base-control__helpder={ uuidFromProps } - __next40pxDefaultSize - help={ - - { errors.uuid - ? errors.uuid - : __( - 'Unique identifier allows you to reference this data source in code.', - 'remote-data-blocks' - ) } - - } - /> - -
- - -
- -
- ) } - + + { errors.displayName + ? errors.displayName + : __( 'Only visible to you and other site managers. ', 'remote-data-blocks' ) } + } label={ __( 'Data Source Name' ) } onChange={ onDisplayNameChange } diff --git a/src/data-sources/components/HttpAuthSettingsInput.tsx b/src/data-sources/components/HttpAuthSettingsInput.tsx index 3e79f219..e1c9eb74 100644 --- a/src/data-sources/components/HttpAuthSettingsInput.tsx +++ b/src/data-sources/components/HttpAuthSettingsInput.tsx @@ -7,10 +7,10 @@ import { HTTP_SOURCE_AUTH_TYPE_SELECT_OPTIONS, HTTP_SOURCE_ADD_TO_SELECT_OPTIONS, } from '@/data-sources/constants'; -import { HttpAuthFormState } from '@/data-sources/http/types'; +import { HttpConfig } from '@/data-sources/types'; interface HttpAuthSettingsInputProps { - auth: HttpAuthFormState; + auth: HttpConfig[ 'service_config' ][ 'auth' ]; onChange: ( id: string, value: unknown ) => void; } @@ -31,21 +31,21 @@ export const HttpAuthSettingsInput: React.FC< HttpAuthSettingsInputProps > = ( { return ( <> - { auth.authType === 'api-key' && ( + { auth?.type === 'api-key' && ( <> = ( { /> onChange( 'authKey', value ) } + value={ auth.key ?? '' } + onChange={ value => onChange( 'key', value ) } help={ __( 'The name of the header or query parameter to add the API key to.', 'remote-data-blocks' @@ -70,12 +70,12 @@ export const HttpAuthSettingsInput: React.FC< HttpAuthSettingsInputProps > = ( { /> ) } - { auth.authType !== 'none' && ( + { auth?.type !== 'none' && ( onChange( 'authValue', value ) } + value={ auth?.value ?? '' } + onChange={ value => onChange( 'value', value ) } __next40pxDefaultSize help={ __( 'The authentication value to use for the HTTP endpoint. When using Basic Auth, this is "username:password" string.', diff --git a/src/data-sources/constants.ts b/src/data-sources/constants.ts index 3d0bd439..eb100b06 100644 --- a/src/data-sources/constants.ts +++ b/src/data-sources/constants.ts @@ -1,5 +1,6 @@ import { __ } from '@wordpress/i18n'; +import { HttpAuthTypes, HttpApiKeyDestination } from '@/data-sources/http/types'; import { SelectOption } from '@/types/input'; export const SUPPORTED_SERVICES = [ @@ -31,26 +32,16 @@ export const GOOGLE_SHEETS_API_SCOPES = [ 'https://www.googleapis.com/auth/spreadsheets.readonly', ]; -/** - * REST API Source Constants - */ -export const AUTH_TYPES = [ 'bearer', 'basic', 'api-key', 'none' ] as const; -export const API_KEY_ADD_TO = [ 'queryparams', 'header' ] as const; - /** * REST API Source SelectOptions */ -export const HTTP_SOURCE_AUTH_TYPE_SELECT_OPTIONS: SelectOption< - ( typeof AUTH_TYPES )[ number ] ->[] = [ +export const HTTP_SOURCE_AUTH_TYPE_SELECT_OPTIONS: SelectOption< HttpAuthTypes >[] = [ + { label: __( 'None', 'remote-data-blocks' ), value: 'none' }, { label: __( 'Bearer', 'remote-data-blocks' ), value: 'bearer' }, { label: __( 'Basic', 'remote-data-blocks' ), value: 'basic' }, { label: __( 'API Key', 'remote-data-blocks' ), value: 'api-key' }, - { label: __( 'None', 'remote-data-blocks' ), value: 'none' }, ]; -export const HTTP_SOURCE_ADD_TO_SELECT_OPTIONS: SelectOption< - ( typeof API_KEY_ADD_TO )[ number ] ->[] = [ +export const HTTP_SOURCE_ADD_TO_SELECT_OPTIONS: SelectOption< HttpApiKeyDestination >[] = [ { label: __( 'Header', 'remote-data-blocks' ), value: 'header' }, { label: __( 'Query Params', 'remote-data-blocks' ), value: 'queryparams' }, ]; diff --git a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx index 6cf194fe..ff304bab 100644 --- a/src/data-sources/google-sheets/GoogleSheetsSettings.tsx +++ b/src/data-sources/google-sheets/GoogleSheetsSettings.tsx @@ -6,45 +6,23 @@ import { ChangeEvent } from 'react'; import { DataSourceForm } from '../components/DataSourceForm'; import { getConnectionMessage } from '../utils'; import { GOOGLE_SHEETS_API_SCOPES } from '@/data-sources/constants'; -import { GoogleSheetsFormState } from '@/data-sources/google-sheets/types'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { useGoogleSpreadsheetsOptions, useGoogleSheetsOptions, } from '@/data-sources/hooks/useGoogleApi'; import { useGoogleAuth } from '@/data-sources/hooks/useGoogleAuth'; -import { GoogleSheetsConfig, SettingsComponentProps } from '@/data-sources/types'; +import { + GoogleSheetsConfig, + GoogleSheetsServiceConfig, + SettingsComponentProps, +} from '@/data-sources/types'; import { useForm, ValidationRules } from '@/hooks/useForm'; -import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import { GoogleSheetsIcon, GoogleSheetsIconWithText } from '@/settings/icons/GoogleSheetsIcon'; import { StringIdName } from '@/types/common'; -import { GoogleServiceAccountKey } from '@/types/google'; import { SelectOption } from '@/types/input'; -const initialState: GoogleSheetsFormState = { - display_name: '', - spreadsheet: null, - sheet: null, - credentials: '', -}; - -const getInitialStateFromConfig = ( config?: GoogleSheetsConfig ): GoogleSheetsFormState => { - if ( ! config ) { - return initialState; - } - - return { - display_name: config.display_name, - spreadsheet: config.spreadsheet, - sheet: config.sheet - ? { - id: config.sheet.id.toString(), - name: config.sheet.name, - } - : null, - credentials: JSON.stringify( config.credentials ), - }; -}; +const SERVICE_CONFIG_VERSION = 1; const defaultSelectOption: SelectOption = { disabled: true, @@ -52,8 +30,8 @@ const defaultSelectOption: SelectOption = { value: '', }; -const validationRules: ValidationRules< GoogleSheetsFormState > = { - credentials: ( state: GoogleSheetsFormState ) => { +const validationRules: ValidationRules< GoogleSheetsServiceConfig > = { + credentials: ( state: Partial< GoogleSheetsServiceConfig > ) => { if ( ! state.credentials ) { return __( 'Please provide credentials JSON for the service account to connect to Google Sheets.', @@ -61,25 +39,19 @@ const validationRules: ValidationRules< GoogleSheetsFormState > = { ); } - try { - JSON.parse( state.credentials ); - } catch ( error ) { - return __( 'Credentials are not valid JSON', 'remote-data-blocks' ); - } return null; }, }; export const GoogleSheetsSettings = ( { mode, - uuid: uuidFromProps, + uuid, config, }: SettingsComponentProps< GoogleSheetsConfig > ) => { - const { goToMainScreen } = useSettingsContext(); - const { updateDataSource, addDataSource } = useDataSources( false ); + const { onSave } = useDataSources< GoogleSheetsConfig >( false ); - const { state, errors, handleOnChange } = useForm< GoogleSheetsFormState >( { - initialValues: getInitialStateFromConfig( config ), + const { state, errors, handleOnChange, validState } = useForm< GoogleSheetsServiceConfig >( { + initialValues: config?.service_config ?? { __version: SERVICE_CONFIG_VERSION }, validationRules, } ); @@ -97,7 +69,7 @@ export const GoogleSheetsSettings = ( { ] ); const { fetchingToken, token, tokenError } = useGoogleAuth( - state.credentials, + JSON.stringify( state.credentials ), GOOGLE_SHEETS_API_SCOPES ); const { spreadsheets, isLoadingSpreadsheets, errorSpreadsheets } = @@ -107,34 +79,18 @@ export const GoogleSheetsSettings = ( { state.spreadsheet?.id ?? '' ); - const [ newUUID, setNewUUID ] = useState< string | null >( uuidFromProps ?? null ); - const onSaveClick = async () => { - if ( ! state.spreadsheet || ! state.sheet || ! state.credentials ) { - // TODO: Error handling + if ( ! validState ) { return; } const data: GoogleSheetsConfig = { - display_name: state.display_name, - uuid: uuidFromProps ?? '', - newUUID: newUUID ?? '', service: 'google-sheets', - - spreadsheet: state.spreadsheet, - sheet: { - id: parseInt( state.sheet.id, 10 ), - name: state.sheet.name, - }, - credentials: JSON.parse( state.credentials ) as GoogleServiceAccountKey, + service_config: validState, + uuid: uuid ?? null, }; - if ( mode === 'add' ) { - await addDataSource( data ); - } else { - await updateDataSource( data ); - } - goToMainScreen(); + return onSave( data, mode ); }; const onCredentialsChange = ( nextValue: string ) => { @@ -204,7 +160,7 @@ export const GoogleSheetsSettings = ( { } return __( 'Select a spreadsheet from which to fetch data.', 'remote-data-blocks' ); - }, [ token, errorSpreadsheets, isLoadingSpreadsheets, state.spreadsheet, spreadsheets ] ); + }, [ token, errorSpreadsheets, isLoadingSpreadsheets, spreadsheets ] ); const sheetHelpText = useMemo( () => { if ( token ) { @@ -219,7 +175,7 @@ export const GoogleSheetsSettings = ( { } return __( 'Select a sheet from which to fetch data.', 'remote-data-blocks' ); - }, [ token, errorSheets, isLoadingSheets, state.sheet, sheets ] ); + }, [ token, errorSheets, isLoadingSheets, sheets ] ); useEffect( () => { if ( ! spreadsheets?.length ) { @@ -253,7 +209,7 @@ export const GoogleSheetsSettings = ( { , - 'spreadsheet' -> & { - sheet: StringIdName | null; - credentials: string; -}; diff --git a/src/data-sources/hooks/useDataSources.ts b/src/data-sources/hooks/useDataSources.ts index 83165b42..943b8484 100644 --- a/src/data-sources/hooks/useDataSources.ts +++ b/src/data-sources/hooks/useDataSources.ts @@ -1,32 +1,21 @@ import apiFetch from '@wordpress/api-fetch'; import { useDispatch } from '@wordpress/data'; -import { useCallback, useEffect, useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { store as noticesStore, NoticeStoreActions, WPNotice } from '@wordpress/notices'; import { REST_BASE_DATA_SOURCES } from '@/data-sources/constants'; import { DataSourceConfig } from '@/data-sources/types'; +import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; -export const useDataSources = ( loadOnMount = true ) => { +export const useDataSources = < SourceConfig extends DataSourceConfig = DataSourceConfig >( + loadOnMount = true +) => { const [ loadingDataSources, setLoadingDataSources ] = useState< boolean >( false ); const [ dataSources, setDataSources ] = useState< DataSourceConfig[] >( [] ); const { createSuccessNotice, createErrorNotice } = useDispatch< NoticeStoreActions >( noticesStore ); - - const checkDisplayNameConflict = useCallback( - ( displayName: string, uuid: string ): boolean => { - if ( ! displayName ) { - return false; - } - - const existingSource = dataSources.find( - source => source.uuid !== uuid && source.display_name === displayName - ); - - return ! existingSource; - }, - [ dataSources ] - ); + const { goToMainScreen } = useSettingsContext(); async function fetchDataSources() { setLoadingDataSources( true ); @@ -39,22 +28,29 @@ export const useDataSources = ( loadOnMount = true ) => { setLoadingDataSources( false ); } - async function updateDataSource( sourceConfig: DataSourceConfig ) { - let result: DataSourceConfig; + async function updateDataSource( sourceConfig: SourceConfig ) { + let result: SourceConfig; try { - const data = { ...sourceConfig }; - if ( sourceConfig.newUUID && sourceConfig.newUUID !== sourceConfig.uuid ) { - data.newUUID = sourceConfig.newUUID; - } - result = await apiFetch( { path: `${ REST_BASE_DATA_SOURCES }/${ sourceConfig.uuid }`, method: 'PUT', - data, + data: sourceConfig.service_config, } ); } catch ( error ) { - showSnackbar( 'error', __( 'Failed to update data source.', 'remote-data-blocks' ) ); + let message = __( 'Failed to update data source.' ); + + if ( + 'object' === typeof error && + null !== error && + 'code' in error && + 'invalid_type' === error?.code && + 'message' in error && + 'string' === typeof error.message + ) { + message = __( error.message, 'remote-data-blocks' ); + } + showSnackbar( 'error', message ); throw error; } @@ -62,14 +58,14 @@ export const useDataSources = ( loadOnMount = true ) => { 'success', sprintf( __( '"%s" has been successfully updated.', 'remote-data-blocks' ), - sourceConfig.display_name + sourceConfig.service_config.display_name ) ); return result; } - async function addDataSource( source: DataSourceConfig ) { - let result: DataSourceConfig; + async function addDataSource( source: SourceConfig ) { + let result: SourceConfig; try { result = await apiFetch( { @@ -77,8 +73,20 @@ export const useDataSources = ( loadOnMount = true ) => { method: 'POST', data: source, } ); - } catch ( error ) { - showSnackbar( 'error', __( 'Failed to add data source.', 'remote-data-blocks' ) ); + } catch ( error: unknown ) { + let message = __( 'Failed to add data source.' ); + + if ( + 'object' === typeof error && + null !== error && + 'code' in error && + 'invalid_type' === error?.code && + 'message' in error && + 'string' === typeof error.message + ) { + message = __( error.message, 'remote-data-blocks' ); + } + showSnackbar( 'error', message ); throw error; } @@ -86,7 +94,7 @@ export const useDataSources = ( loadOnMount = true ) => { 'success', sprintf( __( '"%s" has been successfully added.', 'remote-data-blocks' ), - source.display_name + source.service_config.display_name ) ); return result; @@ -108,11 +116,20 @@ export const useDataSources = ( loadOnMount = true ) => { 'success', sprintf( __( '"%s" has been successfully deleted.', 'remote-data-blocks' ), - source.display_name + source.service_config.display_name ) ); } + async function onSave( config: SourceConfig, mode: 'add' | 'edit' ): Promise< void > { + if ( mode === 'add' ) { + await addDataSource( config ); + } else { + await updateDataSource( config ); + } + goToMainScreen(); + } + function showSnackbar( type: 'success' | 'error', message: string ): void { const SNACKBAR_OPTIONS: Partial< WPNotice > = { isDismissible: true, @@ -136,11 +153,11 @@ export const useDataSources = ( loadOnMount = true ) => { return { addDataSource, - checkDisplayNameConflict, dataSources, deleteDataSource, loadingDataSources, updateDataSource, fetchDataSources, + onSave, }; }; diff --git a/src/data-sources/http/HttpSettings.tsx b/src/data-sources/http/HttpSettings.tsx index 8deb7546..0b8df7c8 100644 --- a/src/data-sources/http/HttpSettings.tsx +++ b/src/data-sources/http/HttpSettings.tsx @@ -1,148 +1,95 @@ import { TextControl } from '@wordpress/components'; -import { useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; import { HttpAuthSettingsInput } from '@/data-sources/components/HttpAuthSettingsInput'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; -import { HttpAuth, HttpAuthFormState, HttpFormState } from '@/data-sources/http/types'; -import { HttpConfig, SettingsComponentProps } from '@/data-sources/types'; +import { HttpAuth } from '@/data-sources/http/types'; +import { HttpConfig, HttpServiceConfig, SettingsComponentProps } from '@/data-sources/types'; import { useForm } from '@/hooks/useForm'; -import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import HttpIcon from '@/settings/icons/HttpIcon'; -const initialState: HttpFormState = { - display_name: '', - url: '', - authType: 'bearer', - authValue: '', - authKey: '', - authAddTo: 'header', -}; +const SERVICE_CONFIG_VERSION = 1; -const getInitialStateFromConfig = ( config?: HttpConfig ): HttpFormState => { - if ( ! config ) { - return initialState; - } +function computeAuthState( updatedAuth: Partial< HttpServiceConfig[ 'auth' ] > ): HttpAuth { + let auth: HttpAuth; - const initialStateFromConfig: HttpFormState = { - display_name: config.display_name, - url: config.url, - authType: config.auth.type, - authValue: config.auth.value, - authKey: '', - authAddTo: 'header', - }; - - if ( config.auth.type === 'api-key' ) { - initialStateFromConfig.authKey = config.auth.key; - initialStateFromConfig.authAddTo = config.auth.addTo; + if ( updatedAuth?.type === 'api-key' ) { + auth = { + type: 'api-key', + value: updatedAuth.value ?? '', + key: updatedAuth.key ?? '', + add_to: updatedAuth.add_to ?? 'header', + }; + } else { + auth = { + type: updatedAuth?.type ?? 'none', + value: updatedAuth?.value ?? '', + }; } - return initialStateFromConfig; -}; - -export const HttpSettings = ( { - mode, - uuid: uuidFromProps, - config, -}: SettingsComponentProps< HttpConfig > ) => { - const { goToMainScreen } = useSettingsContext(); + return auth; +} - const { state, handleOnChange } = useForm< HttpFormState >( { - initialValues: getInitialStateFromConfig( config ), +export const HttpSettings = ( { mode, uuid, config }: SettingsComponentProps< HttpConfig > ) => { + const { state, handleOnChange, validState } = useForm< HttpServiceConfig >( { + initialValues: config?.service_config ?? { + __version: SERVICE_CONFIG_VERSION, + auth: computeAuthState( {} ), + }, } ); - const { addDataSource, updateDataSource } = useDataSources( false ); + const { onSave } = useDataSources< HttpConfig >( false ); - const [ newUUID, setNewUUID ] = useState< string | null >( uuidFromProps ?? null ); + let shouldAllowSubmit: boolean = Boolean( + state.endpoint && state.auth?.type && state.auth?.value + ); + if ( state.auth?.type === 'api-key' ) { + shouldAllowSubmit = shouldAllowSubmit && Boolean( state.auth?.key && state.auth?.add_to ); + } else if ( state.auth?.type === 'none' ) { + shouldAllowSubmit = Boolean( state.endpoint ); + } - const getAuthState = (): HttpAuthFormState => { - return { - authType: state.authType, - authValue: state.authValue, - authKey: state.authKey, - authAddTo: state.authAddTo, - }; + const handleAuthOnChange = ( id: string, value: unknown ): void => { + handleOnChange( 'auth', computeAuthState( { ...state.auth, [ id ]: value } ) ); }; - const shouldAllowSubmit = useMemo( () => { - if ( state.authType === 'api-key' ) { - if ( ! state.authKey || ! state.authAddTo ) { - return false; - } - } - - if ( state.authType === 'none' ) { - return state.url; - } - - return state.url && state.authType && state.authValue; - }, [ state.url, state.authType, state.authValue, state.authKey, state.authAddTo ] ); - const onSaveClick = async () => { - if ( ! shouldAllowSubmit ) { + if ( ! validState || ! shouldAllowSubmit ) { return; } - let auth: HttpAuth; - - if ( state.authType === 'api-key' ) { - auth = { - type: 'api-key', - value: state.authValue, - key: state.authKey, - addTo: state.authAddTo, - }; - } else { - auth = { - type: state.authType, - value: state.authValue, - }; - } - const httpConfig: HttpConfig = { - display_name: state.display_name, - uuid: uuidFromProps ?? '', - newUUID: newUUID ?? '', service: 'generic-http', - url: state.url, - auth, + service_config: validState, + uuid: uuid ?? null, }; - if ( mode === 'add' ) { - await addDataSource( httpConfig ); - } else { - await updateDataSource( httpConfig ); - } - goToMainScreen(); + return onSave( httpConfig, mode ); }; return ( handleOnChange( 'url', value ) } + value={ state.endpoint ?? '' } + onChange={ value => handleOnChange( 'endpoint', value ) } autoComplete="off" __next40pxDefaultSize help={ __( 'The URL for the HTTP endpoint.', 'remote-data-blocks' ) } __nextHasNoMarginBottom /> - + ); diff --git a/src/data-sources/http/types.ts b/src/data-sources/http/types.ts index e6edd720..0ecc64a3 100644 --- a/src/data-sources/http/types.ts +++ b/src/data-sources/http/types.ts @@ -1,8 +1,8 @@ -import { AUTH_TYPES, API_KEY_ADD_TO } from '@/data-sources/constants'; -import { HttpConfig } from '@/data-sources/types'; +export type HttpAuthTypes = 'bearer' | 'basic' | 'api-key' | 'none'; +export type HttpApiKeyDestination = 'header' | 'queryparams'; export interface BaseHttpAuth { - type: ( typeof AUTH_TYPES )[ number ]; + type: HttpAuthTypes; value: string; } @@ -19,18 +19,9 @@ export type HttpAuth = HttpBearerAuth | HttpBasicAuth | HttpApiKeyAuth | HttpNoA export interface HttpApiKeyAuth extends BaseHttpAuth { type: 'api-key'; key: string; - addTo: ( typeof API_KEY_ADD_TO )[ number ]; + add_to: HttpApiKeyDestination; } export interface HttpNoAuth extends BaseHttpAuth { type: 'none'; } - -export type HttpAuthFormState = { - authType: ( typeof AUTH_TYPES )[ number ]; - authValue: string; - authKey: string; - authAddTo: ( typeof API_KEY_ADD_TO )[ number ]; -}; - -export type HttpFormState = Omit< HttpConfig, 'service' | 'uuid' | 'auth' > & HttpAuthFormState; diff --git a/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx b/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx index 706f0311..b55bcb09 100644 --- a/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx +++ b/src/data-sources/salesforce-b2c/SalesforceB2CSettings.tsx @@ -1,51 +1,29 @@ import { TextControl } from '@wordpress/components'; -import { useMemo, useState } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; -import { SettingsComponentProps, SalesforceB2CConfig } from '@/data-sources/types'; +import { + SettingsComponentProps, + SalesforceB2CConfig, + SalesforceB2CServiceConfig, +} from '@/data-sources/types'; import { useForm } from '@/hooks/useForm'; -import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import SalesforceCommerceB2CIcon from '@/settings/icons/SalesforceCommerceB2CIcon'; -export type SalesforceB2CFormState = Omit< SalesforceB2CConfig, 'service' | 'uuid' >; - -const initialState: SalesforceB2CFormState = { - display_name: '', - shortcode: '', - organization_id: '', - client_id: '', - client_secret: '', -}; - -const getInitialStateFromConfig = ( config?: SalesforceB2CConfig ): SalesforceB2CFormState => { - if ( ! config ) { - return initialState; - } - - return { - display_name: config.display_name, - shortcode: config.shortcode, - organization_id: config.organization_id, - client_id: config.client_id, - client_secret: config.client_secret, - }; -}; +const SERVICE_CONFIG_VERSION = 1; export const SalesforceB2CSettings = ( { mode, - uuid: uuidFromProps, + uuid, config, }: SettingsComponentProps< SalesforceB2CConfig > ) => { - const { goToMainScreen } = useSettingsContext(); - const { updateDataSource, addDataSource } = useDataSources( false ); + const { onSave } = useDataSources< SalesforceB2CConfig >( false ); - const [ newUUID, setNewUUID ] = useState< string | null >( uuidFromProps ?? null ); - - const { state, handleOnChange } = useForm< SalesforceB2CFormState >( { - initialValues: getInitialStateFromConfig( config ), + const { state, handleOnChange, validState } = useForm< SalesforceB2CServiceConfig >( { + initialValues: config?.service_config ?? { __version: SERVICE_CONFIG_VERSION }, } ); const shouldAllowSubmit = useMemo( () => { @@ -53,31 +31,24 @@ export const SalesforceB2CSettings = ( { }, [ state.shortcode, state.organization_id, state.client_id, state.client_secret ] ); const onSaveClick = async () => { - const salesforceConfig: SalesforceB2CConfig = { - uuid: uuidFromProps ?? '', - newUUID: newUUID ?? '', - display_name: state.display_name, + if ( ! validState ) { + return; + } + + const data: SalesforceB2CConfig = { service: 'salesforce-b2c', - shortcode: state.shortcode, - organization_id: state.organization_id, - client_id: state.client_id, - client_secret: state.client_secret, + service_config: validState, + uuid: uuid ?? null, }; - if ( mode === 'add' ) { - await addDataSource( salesforceConfig ); - } else { - await updateDataSource( salesforceConfig ); - } - - goToMainScreen(); + return onSave( data, mode ); }; return ( { handleOnChange( 'shortcode', shortCode ?? '' ); } } - value={ state.shortcode } + value={ state.shortcode ?? '' } help={ __( 'The region-specific merchant identifier. Example: 0dnz6ope' ) } autoComplete="off" __next40pxDefaultSize @@ -108,7 +76,7 @@ export const SalesforceB2CSettings = ( { onChange={ shortCode => { handleOnChange( 'organization_id', shortCode ?? '' ); } } - value={ state.organization_id } + value={ state.organization_id ?? '' } help={ __( 'The organization ID. Example: f_ecom_mirl_012' ) } autoComplete="off" __next40pxDefaultSize @@ -120,7 +88,7 @@ export const SalesforceB2CSettings = ( { onChange={ shortCode => { handleOnChange( 'client_id', shortCode ?? '' ); } } - value={ state.client_id } + value={ state.client_id ?? '' } help={ __( 'Example: bc2991f1-eec8-4976-8774-935cbbe84f18' ) } autoComplete="off" __next40pxDefaultSize diff --git a/src/data-sources/shopify/ShopifySettings.tsx b/src/data-sources/shopify/ShopifySettings.tsx index 58fd13ff..7e6a3136 100644 --- a/src/data-sources/shopify/ShopifySettings.tsx +++ b/src/data-sources/shopify/ShopifySettings.tsx @@ -1,76 +1,37 @@ import { TextControl } from '@wordpress/components'; -import { useMemo, useState } from '@wordpress/element'; +import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { DataSourceForm } from '../components/DataSourceForm'; import PasswordInputControl from '@/data-sources/components/PasswordInputControl'; import { useDataSources } from '@/data-sources/hooks/useDataSources'; import { useShopifyShopName } from '@/data-sources/hooks/useShopify'; -import { SettingsComponentProps, ShopifyConfig } from '@/data-sources/types'; +import { SettingsComponentProps, ShopifyConfig, ShopifyServiceConfig } from '@/data-sources/types'; import { useForm } from '@/hooks/useForm'; -import { useSettingsContext } from '@/settings/hooks/useSettingsNav'; import { ShopifyIcon, ShopifyIconWithText } from '@/settings/icons/ShopifyIcon'; -export type ShopifyFormState = Omit< ShopifyConfig, 'service' | 'uuid' >; - -const initialState: ShopifyFormState = { - display_name: '', - store_name: '', - access_token: '', -}; - -const getInitialStateFromConfig = ( config?: ShopifyConfig ): ShopifyFormState => { - if ( ! config ) { - return initialState; - } - return { - display_name: config.display_name, - store_name: config.store_name, - access_token: config.access_token, - }; -}; +const SERVICE_CONFIG_VERSION = 1; export const ShopifySettings = ( { mode, - uuid: uuidFromProps, + uuid, config, }: SettingsComponentProps< ShopifyConfig > ) => { - const { goToMainScreen } = useSettingsContext(); - const { updateDataSource, addDataSource } = useDataSources( false ); + const { onSave } = useDataSources< ShopifyConfig >( false ); - const { state, handleOnChange } = useForm< ShopifyFormState >( { - initialValues: getInitialStateFromConfig( config ), + const { state, handleOnChange, validState } = useForm< ShopifyServiceConfig >( { + initialValues: config?.service_config ?? { __version: SERVICE_CONFIG_VERSION }, } ); const { shopName, connectionMessage } = useShopifyShopName( - state.store_name, - state.access_token + state.store_name ?? '', + state.access_token ?? '' ); - const [ newUUID, setNewUUID ] = useState< string | null >( uuidFromProps ?? null ); - const shouldAllowSubmit = useMemo( () => { return state.store_name && state.access_token; }, [ state.store_name, state.access_token ] ); - const onSaveClick = async () => { - const shopifyConfig: ShopifyConfig = { - display_name: state.display_name, - uuid: uuidFromProps ?? '', - newUUID: newUUID ?? '', - service: 'shopify', - store_name: state.store_name, - access_token: state.access_token, - }; - - if ( mode === 'add' ) { - await addDataSource( shopifyConfig ); - } else { - await updateDataSource( shopifyConfig ); - } - goToMainScreen(); - }; - const onTokenInputChange = ( token: string | undefined ) => { handleOnChange( 'access_token', token ?? '' ); }; @@ -86,23 +47,34 @@ export const ShopifySettings = ( { handleOnChange( 'store_name', extractedShopName ); }; + const onSaveClick = async () => { + if ( ! validState ) { + return; + } + + const data: ShopifyConfig = { + service: 'shopify', + service_config: validState, + uuid: uuid ?? null, + }; + + return onSave( data, mode ); + }; + return ( diff --git a/src/data-sources/types.ts b/src/data-sources/types.ts index b602743e..1c9baf78 100644 --- a/src/data-sources/types.ts +++ b/src/data-sources/types.ts @@ -5,11 +5,18 @@ import { GoogleServiceAccountKey } from '@/types/google'; export type DataSourceType = ( typeof SUPPORTED_SERVICES )[ number ]; -interface BaseDataSourceConfig { +interface BaseServiceConfig extends Record< string, unknown > { + __version: number; display_name: string; - uuid: string; - newUUID?: string; - service: DataSourceType; +} + +interface BaseDataSourceConfig< + ServiceName extends DataSourceType, + ServiceConfig extends BaseServiceConfig +> { + service: ServiceName; + service_config: ServiceConfig; + uuid: string | null; } export interface DataSourceQueryMappingValue { @@ -43,40 +50,44 @@ export interface AirtableTableConfig extends StringIdName { output_query_mappings: AirtableOutputQueryMappingValue[]; } -export interface AirtableConfig extends BaseDataSourceConfig { - service: 'airtable'; +export interface AirtableServiceConfig extends BaseServiceConfig { access_token: string; base: StringIdName; tables: AirtableTableConfig[]; } -export interface GoogleSheetsConfig extends BaseDataSourceConfig { - service: 'google-sheets'; +export interface GoogleSheetsServiceConfig extends BaseServiceConfig { credentials: GoogleServiceAccountKey; spreadsheet: StringIdName; sheet: NumberIdName; } -export interface HttpConfig extends BaseDataSourceConfig { - service: 'generic-http'; - url: string; - auth: HttpAuth; +export interface HttpServiceConfig extends BaseServiceConfig { + auth?: HttpAuth; + endpoint: string; } -export interface SalesforceB2CConfig extends BaseDataSourceConfig { - service: 'salesforce-b2c'; +export interface SalesforceB2CServiceConfig extends BaseServiceConfig { shortcode: string; organization_id: string; client_id: string; client_secret: string; } -export interface ShopifyConfig extends BaseDataSourceConfig { - service: 'shopify'; +export interface ShopifyServiceConfig extends BaseServiceConfig { access_token: string; store_name: string; } +export type AirtableConfig = BaseDataSourceConfig< 'airtable', AirtableServiceConfig >; +export type GoogleSheetsConfig = BaseDataSourceConfig< 'google-sheets', GoogleSheetsServiceConfig >; +export type HttpConfig = BaseDataSourceConfig< 'generic-http', HttpServiceConfig >; +export type SalesforceB2CConfig = BaseDataSourceConfig< + 'salesforce-b2c', + SalesforceB2CServiceConfig +>; +export type ShopifyConfig = BaseDataSourceConfig< 'shopify', ShopifyServiceConfig >; + export type DataSourceConfig = | AirtableConfig | GoogleSheetsConfig @@ -84,7 +95,7 @@ export type DataSourceConfig = | SalesforceB2CConfig | ShopifyConfig; -export type SettingsComponentProps< T extends BaseDataSourceConfig > = { +export type SettingsComponentProps< T extends DataSourceConfig > = { mode: 'add' | 'edit'; uuid?: string; config?: T; diff --git a/src/hooks/useForm.ts b/src/hooks/useForm.ts index f8e260bc..0185dff8 100644 --- a/src/hooks/useForm.ts +++ b/src/hooks/useForm.ts @@ -2,10 +2,10 @@ import { useReducer, useState } from '@wordpress/element'; import { isNonEmptyObj, constructObjectWithValues } from '@/utils/object'; -export type ValidationRuleFn< T > = ( v: T ) => string | null; +export type ValidationRuleFn< T > = ( v: Partial< T > ) => string | null; export type ValidationRules< T > = { - [ P in keyof T ]?: ValidationRuleFn< T >; + [ P in keyof Omit< T, '__version' | 'display_name' > ]: ValidationRuleFn< T >; }; const executeValidationRules = < T >( rule: ValidationRuleFn< T >, value: T ): string | null => { @@ -43,14 +43,15 @@ const executeAllValidationRules = < T >( }; interface UseForm< T > { - state: T; + state: Partial< T >; errors: { [ x: string ]: string | null }; - setFormState: ( newState: T ) => void; + setFormState: ( newState: Partial< T > ) => void; resetFormState: () => void; resetErrorState: () => void; handleOnChange: ( id: string, value: unknown ) => void; handleOnBlur: ( id: string ) => void; handleOnSubmit: () => void; + validState: T | null; } export interface ValidationFnResponse { @@ -59,10 +60,10 @@ export interface ValidationFnResponse { } interface UseFormProps< T > { - initialValues: T; - validationRules?: ValidationRules< T >; + initialValues: Partial< T >; + validationRules?: ValidationRules< Partial< T > >; submit?: ( state: T, resetForm: () => void ) => void; - submitValidationFn?: ( state: T ) => ValidationFnResponse; + submitValidationFn?: ( state: Partial< T > ) => ValidationFnResponse; } type FormAction< T > = @@ -82,11 +83,14 @@ const reducer = < T >( state: T, action: FormAction< T > ): T => { export const useForm = < T extends Record< string, unknown > >( { initialValues, - validationRules = {} as ValidationRules< T >, + validationRules = {} as ValidationRules< Partial< T > >, submit, submitValidationFn, }: UseFormProps< T > ): UseForm< T > => { - const [ state, dispatch ] = useReducer< typeof reducer< T > >( reducer, initialValues ); + const [ state, dispatch ] = useReducer< typeof reducer< Partial< T > > >( + reducer, + initialValues + ); const [ touched, setTouched ] = useState( constructObjectWithValues< boolean >( validationRules, false ) ); @@ -103,7 +107,7 @@ export const useForm = < T extends Record< string, unknown > >( { resetErrorState(); }; - const setFormState = ( newState: T ): void => { + const setFormState = ( newState: Partial< T > ): void => { dispatch( { type: 'setState', payload: { value: newState } } ); }; @@ -131,9 +135,14 @@ export const useForm = < T extends Record< string, unknown > >( { } ); }; + const validation = executeAllValidationRules( validationRules, state ); + const validateState = ( _partialState: Partial< T > ): _partialState is T => { + return ! validation.hasError; + }; + const handleOnSubmit = (): void => { if ( isNonEmptyObj( errors ) ) { - const { errorsObj, hasError } = executeAllValidationRules( validationRules, state ); + const { errorsObj, hasError } = validation; let finalErrorsObj: { [ x: string ]: string | null }; let finalHasError: boolean; if ( submitValidationFn && typeof submitValidationFn === 'function' ) { @@ -149,10 +158,10 @@ export const useForm = < T extends Record< string, unknown > >( { finalHasError = hasError; } setErrors( finalErrorsObj ); - if ( ! finalHasError && submit ) { + if ( ! finalHasError && submit && validateState( state ) ) { submit( state, resetFormState ); } - } else if ( submit ) { + } else if ( submit && validateState( state ) ) { submit( state, resetFormState ); } }; @@ -166,6 +175,7 @@ export const useForm = < T extends Record< string, unknown > >( { handleOnChange, handleOnBlur, handleOnSubmit, + validState: validateState( state ) ? state : null, }; }; diff --git a/tests/inc/Analytics/TracksAnalyticsTest.php b/tests/inc/Analytics/TracksAnalyticsTest.php index 80bbd632..fc0b0c75 100644 --- a/tests/inc/Analytics/TracksAnalyticsTest.php +++ b/tests/inc/Analytics/TracksAnalyticsTest.php @@ -6,9 +6,9 @@ use PHPUnit\Framework\MockObject\MockObject; use RemoteDataBlocks\Analytics\TracksAnalytics; use RemoteDataBlocks\Analytics\EnvironmentConfig; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; +use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; -use RemoteDataBlocks\ExampleApi\Queries\ExampleApiDataSource; use RemoteDataBlocks\Integrations\Shopify\ShopifyDataSource; // Define a mock class for Tracks. @@ -133,7 +133,7 @@ public function testTrackPluginDeactivationDoesRecordEventIfPluginIsRDB(): void /** @var MockObject|EnvironmentConfig */ $env_config_mock = $this->getMockBuilder( EnvironmentConfig::class )->onlyMethods( [ 'is_remote_data_blocks_plugin' ] )->getMock(); $env_config_mock->method( 'is_remote_data_blocks_plugin' )->with()->willReturn( true ); - + /** @var MockTracks|MockObject */ $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 1 ) )->method( 'record_event' )->with( 'remotedatablocks_plugin_toggle', $this->isType( 'array' ) ); @@ -161,11 +161,11 @@ public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostStatusIsNot /** @var MockObject|EnvironmentConfig */ $env_config_mock = $this->getMockBuilder( EnvironmentConfig::class )->onlyMethods( [ 'should_track_post_having_remote_data_blocks' ] )->getMock(); $env_config_mock->method( 'should_track_post_having_remote_data_blocks' )->with( 1 )->willReturn( true ); - + /** @var MockTracks|MockObject */ $tracks_mock = $this->getMockBuilder( MockTracks::class )->onlyMethods( [ 'record_event' ] )->getMock(); $tracks_mock->expects( $this->exactly( 0 ) )->method( 'record_event' ); - + set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); TracksAnalytics::init( $env_config_mock ); TracksAnalytics::track_remote_data_blocks_usage( 1, (object) [ 'post_status' => 'draft' ] ); @@ -178,8 +178,15 @@ public function testTrackRemoteDataBlocksUsageDoesNotTrackEventIfPostContentHave // Setup data sources. ConfigStore::init(); - ConfigStore::set_configuration( 'remote-data-blocks/shopify-vip-store', [ - 'queries' => [ new HttpQueryContext( ShopifyDataSource::create( 'access_token', 'name' ) ) ], + ConfigStore::set_block_configuration( 'remote-data-blocks/shopify-vip-store', [ + 'queries' => [ + 'display' => HttpQuery::from_array( [ + 'data_source' => ShopifyDataSource::from_array( [ + 'access_token' => 'token', + 'store_name' => 'B. Walton', + ] ), + ] ), + ], ] ); /** @var MockTracks|MockObject */ @@ -202,14 +209,34 @@ public function testTrackRemoteDataBlocksUsageDoesTrackEventIfPostContentHaveRem // Setup data sources. ConfigStore::init(); - ConfigStore::set_configuration( 'remote-data-blocks/shopify-vip-store', [ - 'queries' => [ new HttpQueryContext( ShopifyDataSource::create( 'access_token', 'name' ) ) ], + ConfigStore::set_block_configuration( 'remote-data-blocks/shopify-vip-store', [ + 'queries' => [ + 'display' => HttpQuery::from_array( [ + 'data_source' => ShopifyDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token', + 'display_name' => 'Shopify Source', + 'store_name' => 'B. Walton', + ], + ] ), + 'output_schema' => [ 'type' => 'string' ], + ] ), + ], ] ); - ConfigStore::set_configuration( 'remote-data-blocks/conference-event', [ + + ConfigStore::set_block_configuration( 'remote-data-blocks/conference-event', [ 'queries' => [ - new HttpQueryContext( ExampleApiDataSource::from_array( [ - 'service' => 'example_api', - ] ) ), + 'display' => HttpQuery::from_array( [ + 'data_source' => HttpDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'HTTP Source', + 'endpoint' => 'https://example.com/api/v1', + ], + ] ), + 'output_schema' => [ 'type' => 'string' ], + ] ), ], ] ); @@ -220,7 +247,7 @@ public function testTrackRemoteDataBlocksUsageDoesTrackEventIfPostContentHaveRem 'post_type' => 'post', 'shopify_data_source_count' => 2, 'remote_data_blocks_total_count' => 3, - 'example_api_data_source_count' => 1, + 'generic-http_data_source_count' => 1, ] ); set_private_property( TracksAnalytics::class, null, 'instance', $tracks_mock ); diff --git a/tests/inc/Config/HttpDataSourceTest.php b/tests/inc/Config/HttpDataSourceTest.php index bf51c0a4..340fd493 100644 --- a/tests/inc/Config/HttpDataSourceTest.php +++ b/tests/inc/Config/HttpDataSourceTest.php @@ -4,20 +4,25 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Tests\Mocks\MockDataSource; -use RemoteDataBlocks\Tests\Mocks\MockValidator; class HttpDataSourceTest extends TestCase { private MockDataSource $http_data_source; - public function testGetServiceMethodReturnsNull(): void { - $this->http_data_source = MockDataSource::from_array( [], new MockValidator() ); + public function testGetServiceMethodCannotBeOverriddenl(): void { + $config = [ + 'service' => 'mock', + 'service_config' => [ + 'endpoint' => 'http://example.com', + ], + ]; + $this->http_data_source = MockDataSource::from_array( $config ); - $this->assertNull( $this->http_data_source->get_service() ); + $this->assertSame( 'generic-http', $this->http_data_source->get_service_name() ); } public function testGetServiceMethodReturnsCorrectValue(): void { - $this->http_data_source = MockDataSource::from_array( MockDataSource::MOCK_CONFIG, new MockValidator() ); + $this->http_data_source = MockDataSource::from_array(); - $this->assertEquals( 'mock', $this->http_data_source->get_service() ); + $this->assertEquals( 'generic-http', $this->http_data_source->get_service_name() ); } } diff --git a/tests/inc/Config/QueryRunnerTest.php b/tests/inc/Config/QueryRunnerTest.php index f3702b46..0d9d6fb2 100644 --- a/tests/inc/Config/QueryRunnerTest.php +++ b/tests/inc/Config/QueryRunnerTest.php @@ -4,79 +4,27 @@ use GuzzleHttp\Psr7\Response; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\DataSource\HttpDataSource; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; use RemoteDataBlocks\Config\QueryRunner\QueryRunner; -use RemoteDataBlocks\Config\QueryRunner\QueryRunnerInterface; use RemoteDataBlocks\HttpClient\HttpClient; use RemoteDataBlocks\Tests\Mocks\MockDataSource; -use RemoteDataBlocks\Tests\Mocks\MockValidator; +use RemoteDataBlocks\Tests\Mocks\MockQuery; use WP_Error; class QueryRunnerTest extends TestCase { private MockDataSource $http_data_source; - private HttpQueryContext $query_context; + private MockQuery $query; private HttpClient $http_client; protected function setUp(): void { parent::setUp(); $this->http_client = $this->createMock( HttpClient::class ); - $this->http_data_source = MockDataSource::from_array( MockDataSource::MOCK_CONFIG, new MockValidator() ); + $this->http_data_source = MockDataSource::from_array(); - $this->query_context = new class($this->http_data_source, $this->http_client) extends HttpQueryContext { - private string $request_method = 'GET'; - private array $request_body = [ 'query' => 'test' ]; - private mixed $response_data = null; - - public function __construct( private HttpDataSource $http_data_source, private HttpClient $http_client ) { - parent::__construct( $http_data_source ); - } - - public function get_endpoint( array $input_variables = [] ): string { - return $this->http_data_source->get_endpoint(); - } - - public function get_image_url(): ?string { - return null; - } - - public function get_request_method(): string { - return $this->request_method; - } - - public function get_request_body( array $input_variables ): array|null { - return $this->request_body; - } - - public function get_query_name(): string { - return 'Query'; - } - - public function get_query_runner(): QueryRunnerInterface { - return new QueryRunner( $this, $this->http_client ); - } - - public function process_response( string $raw_response_data, array $input_variables ): string|array|object|null { - if ( null !== $this->response_data ) { - return $this->response_data; - } - - return $raw_response_data; - } - - public function set_request_method( string $method ): void { - $this->request_method = $method; - } - - public function set_request_body( array $body ): void { - $this->request_body = $body; - } - - public function set_response_data( string|array|object|null $data ): void { - $this->response_data = $data; - } - }; + $this->query = MockQuery::from_array( [ + 'data_source' => $this->http_data_source, + 'query_runner' => new QueryRunner( $this->http_client ), + ] ); } public static function provideValidEndpoints(): array { @@ -112,7 +60,6 @@ public static function provideValidEndpoints(): array { * @dataProvider provideValidEndpoints */ public function testExecuteSuccessfulRequest( string $endpoint ) { - $input_variables = [ 'key' => 'value' ]; $response_body = wp_json_encode( [ 'data' => [ 'id' => 1, @@ -121,11 +68,25 @@ public function testExecuteSuccessfulRequest( string $endpoint ) { ] ); $response = new Response( 200, [], $response_body ); + $this->query->set_output_schema( [ + 'is_collection' => false, + 'path' => '$.data', + 'type' => [ + 'id' => [ + 'name' => 'ID', + 'type' => 'id', + ], + 'name' => [ + 'name' => 'Name', + 'type' => 'string', + ], + ], + ] ); + $this->http_data_source->set_endpoint( $endpoint ); $this->http_client->method( 'request' )->willReturn( $response ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( $input_variables ); + $result = $this->query->execute( [] ); $this->assertIsArray( $result ); $this->assertArrayHasKey( 'is_collection', $result ); @@ -177,45 +138,36 @@ public static function provideInvalidEndpoints(): array { * @dataProvider provideInvalidEndpoints */ public function testExecuteInvalidEndpoints( string $endpoint, string $expected_error_code ) { - $input_variables = [ 'key' => 'value' ]; - $this->http_data_source->set_endpoint( $endpoint ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( $input_variables ); + $result = $this->query->execute( [] ); $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( $expected_error_code, $result->get_error_code() ); } public function testExecuteHttpClientException() { - $input_variables = [ 'key' => 'value' ]; - $this->http_client->method( 'request' )->willThrowException( new \Exception( 'HTTP Client Error' ) ); - $query_runner = new QueryRunner( $this->query_context, $this->http_client ); - $result = $query_runner->execute( $input_variables ); + $query_runner = new QueryRunner( $this->http_client ); + $result = $query_runner->execute( $this->query, [] ); $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'remote-data-blocks-unexpected-exception', $result->get_error_code() ); } public function testExecuteBadStatusCode() { - $input_variables = [ 'key' => 'value' ]; - $response = new \GuzzleHttp\Psr7\Response( 400, [], 'Bad Request' ); $this->http_client->method( 'request' )->willReturn( $response ); - $query_runner = new QueryRunner( $this->query_context, $this->http_client ); - $result = $query_runner->execute( $input_variables ); + $query_runner = new QueryRunner( $this->http_client ); + $result = $query_runner->execute( $this->query, [] ); $this->assertInstanceOf( WP_Error::class, $result ); $this->assertSame( 'remote-data-blocks-bad-status-code', $result->get_error_code() ); } public function testExecuteSuccessfulResponse() { - $input_variables = [ 'key' => 'value' ]; - $response_body = $this->createMock( \Psr\Http\Message\StreamInterface::class ); $response_body->method( 'getContents' )->willReturn( wp_json_encode( [ 'test' => 'test value' ] ) ); @@ -223,19 +175,18 @@ public function testExecuteSuccessfulResponse() { $this->http_client->method( 'request' )->willReturn( $response ); - $this->query_context->output_schema = [ + $this->query->set_output_schema( [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'test' => [ 'name' => 'Test Field', 'path' => '$.test', 'type' => 'string', ], ], - ]; + ] ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( $input_variables ); + $result = $this->query->execute( [] ); $this->assertIsArray( $result ); $this->assertArrayHasKey( 'is_collection', $result ); @@ -250,7 +201,6 @@ public function testExecuteSuccessfulResponse() { 'result' => [ 'test' => [ 'name' => 'Test Field', - 'path' => '$.test', 'type' => 'string', 'value' => 'test value', ], @@ -268,20 +218,19 @@ public function testExecuteSuccessfulResponseWithJsonStringResponseData() { $this->http_client->method( 'request' )->willReturn( $response ); - $this->query_context->set_response_data( '{"test":"overridden in process_response as JSON string"}' ); - $this->query_context->output_schema = [ + $this->query->set_response_data( '{"test":"overridden in preprocess_response as JSON string"}' ); + $this->query->set_output_schema( [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'test' => [ 'name' => 'Test Field', 'path' => '$.test', 'type' => 'string', ], ], - ]; + ] ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( [] ); + $result = $this->query->execute( [] ); $this->assertIsArray( $result ); $this->assertArrayHasKey( 'is_collection', $result ); @@ -296,9 +245,8 @@ public function testExecuteSuccessfulResponseWithJsonStringResponseData() { 'result' => [ 'test' => [ 'name' => 'Test Field', - 'path' => '$.test', 'type' => 'string', - 'value' => 'overridden in process_response as JSON string', + 'value' => 'overridden in preprocess_response as JSON string', ], ], ]; @@ -310,24 +258,24 @@ public function testExecuteSuccessfulResponseWithJsonStringResponseData() { public function testExecuteSuccessfulResponseWithArrayResponseData() { $response_body = $this->createMock( \Psr\Http\Message\StreamInterface::class ); + $response = new Response( 200, [], $response_body ); $this->http_client->method( 'request' )->willReturn( $response ); - $this->query_context->set_response_data( [ 'test' => 'overridden in process_response as array' ] ); - $this->query_context->output_schema = [ + $this->query->set_response_data( [ 'test' => 'overridden in preprocess_response as array' ] ); + $this->query->set_output_schema( [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'test' => [ 'name' => 'Test Field', 'path' => '$.test', 'type' => 'string', ], ], - ]; + ] ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( [] ); + $result = $this->query->execute( [] ); $this->assertIsArray( $result ); $this->assertArrayHasKey( 'is_collection', $result ); @@ -342,9 +290,8 @@ public function testExecuteSuccessfulResponseWithArrayResponseData() { 'result' => [ 'test' => [ 'name' => 'Test Field', - 'path' => '$.test', 'type' => 'string', - 'value' => 'overridden in process_response as array', + 'value' => 'overridden in preprocess_response as array', ], ], ]; @@ -361,22 +308,21 @@ public function testExecuteSuccessfulResponseWithObjectResponseData() { $this->http_client->method( 'request' )->willReturn( $response ); $response_data = new \stdClass(); - $response_data->test = 'overridden in process_response as object'; + $response_data->test = 'overridden in preprocess_response as object'; - $this->query_context->set_response_data( $response_data ); - $this->query_context->output_schema = [ + $this->query->set_response_data( $response_data ); + $this->query->set_output_schema( [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'test' => [ 'name' => 'Test Field', 'path' => '$.test', 'type' => 'string', ], ], - ]; + ] ); - $query_runner = $this->query_context->get_query_runner(); - $result = $query_runner->execute( [] ); + $result = $this->query->execute( [] ); $this->assertIsArray( $result ); $this->assertArrayHasKey( 'is_collection', $result ); @@ -391,9 +337,8 @@ public function testExecuteSuccessfulResponseWithObjectResponseData() { 'result' => [ 'test' => [ 'name' => 'Test Field', - 'path' => '$.test', 'type' => 'string', - 'value' => 'overridden in process_response as object', + 'value' => 'overridden in preprocess_response as object', ], ], ]; diff --git a/tests/inc/Config/QueryContextTest.php b/tests/inc/Config/QueryTest.php similarity index 57% rename from tests/inc/Config/QueryContextTest.php rename to tests/inc/Config/QueryTest.php index 5d0d614a..030beb77 100644 --- a/tests/inc/Config/QueryContextTest.php +++ b/tests/inc/Config/QueryTest.php @@ -3,17 +3,19 @@ namespace RemoteDataBlocks\Tests\Config; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Tests\Mocks\MockDataSource; -use RemoteDataBlocks\Tests\Mocks\MockValidator; -class QueryContextTest extends TestCase { +class QueryTest extends TestCase { private MockDataSource $data_source; - private HttpQueryContext $query_context; + private HttpQuery $query_context; protected function setUp(): void { - $this->data_source = MockDataSource::from_array( MockDataSource::MOCK_CONFIG, new MockValidator() ); - $this->query_context = new HttpQueryContext( $this->data_source ); + $this->data_source = MockDataSource::from_array(); + $this->query_context = HttpQuery::from_array( [ + 'data_source' => $this->data_source, + 'output_schema' => [ 'type' => 'null' ], + ] ); } public function testGetEndpoint() { @@ -39,28 +41,19 @@ public function testGetRequestBody() { $this->assertNull( $this->query_context->get_request_body( [] ) ); } - public function testGetQueryName() { - $this->assertSame( 'Query', $this->query_context->get_query_name() ); - } - - public function testIsResponseDataCollection() { - $this->assertFalse( $this->query_context->is_response_data_collection() ); - - $this->query_context->output_schema['is_collection'] = true; - $this->assertTrue( $this->query_context->is_response_data_collection() ); - } - - public function testDefaultProcessResponse() { + public function testDefaultPreprocessResponse() { $raw_data = '{"key": "value"}'; - $this->assertSame( $raw_data, $this->query_context->process_response( $raw_data, [] ) ); + $this->assertSame( $raw_data, $this->query_context->preprocess_response( $raw_data, [] ) ); } - public function testCustomProcessResponse() { - $custom_query_context = new class($this->data_source) extends HttpQueryContext { - public function process_response( string $raw_response_data, array $input_variables ): string { + public function testCustomPreprocessResponse() { + $custom_query_context = HttpQuery::from_array( [ + 'data_source' => $this->data_source, + 'output_schema' => [ 'type' => 'string' ], + 'preprocess_response' => function ( mixed $response_data ): mixed { // Convert HTML to JSON $dom = new \DOMDocument(); - $dom->loadHTML( $raw_response_data, LIBXML_NOERROR ); + $dom->loadHTML( $response_data, LIBXML_NOERROR ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $title = $dom->getElementsByTagName( 'title' )->item( 0 )->nodeValue; $paragraphs = $dom->getElementsByTagName( 'p' ); @@ -69,19 +62,19 @@ public function process_response( string $raw_response_data, array $input_variab // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $content[] = $p->nodeValue; } - + $data = [ 'title' => $title, 'content' => $content, ]; - + return wp_json_encode( $data ); - } - }; + }, + ] ); $html_data = 'Test Page

Paragraph 1

Paragraph 2

'; $expected_json = '{"title":"Test Page","content":["Paragraph 1","Paragraph 2"]}'; - - $this->assertSame( $expected_json, $custom_query_context->process_response( $html_data, [] ) ); + + $this->assertSame( $expected_json, $custom_query_context->preprocess_response( $html_data, [] ) ); } } diff --git a/tests/inc/Editor/ConfigStoreTest.php b/tests/inc/Editor/ConfigStoreTest.php index 76a7e739..ffd99cae 100644 --- a/tests/inc/Editor/ConfigStoreTest.php +++ b/tests/inc/Editor/ConfigStoreTest.php @@ -3,7 +3,7 @@ namespace RemoteDataBlocks\Tests\Editor\BlockPatterns; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; +use RemoteDataBlocks\Config\Query\HttpQuery; use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; use RemoteDataBlocks\Integrations\Airtable\AirtableDataSource; @@ -16,15 +16,30 @@ public function testGetDataSourceReturnsNullIfConfigIsNotFound(): void { public function testGetDataSourceReturnsNullIfThereAreNoQueries(): void { ConfigStore::init(); - ConfigStore::set_configuration( 'block_name', [ 'queries' => [] ] ); + ConfigStore::set_block_configuration( 'block_name', [ 'queries' => [] ] ); $this->assertNull( ConfigStore::get_data_source_type( 'block_name' ) ); } public function testGetDataSourceReturnsDataSource(): void { ConfigStore::init(); - ConfigStore::set_configuration( 'airtable_remote_blocks', [ - 'queries' => [ new HttpQueryContext( AirtableDataSource::create( 'access_token', 'base_id', [], 'Name' ) ) ], + ConfigStore::set_block_configuration( 'airtable_remote_blocks', [ + 'queries' => [ + 'display' => HttpQuery::from_array( [ + 'data_source' => AirtableDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token', + 'base' => [ + 'id' => 'foo', + ], + 'display_name' => 'Name', + 'tables' => [], + ], + ] ), + 'output_schema' => [ 'type' => 'string' ], + ] ), + ], ] ); $this->assertEquals( 'airtable', ConfigStore::get_data_source_type( 'airtable_remote_blocks' ) ); diff --git a/tests/inc/Editor/DataBinding/BlockBindingsTest.php b/tests/inc/Editor/DataBinding/BlockBindingsTest.php index c5064364..1a67fe18 100644 --- a/tests/inc/Editor/DataBinding/BlockBindingsTest.php +++ b/tests/inc/Editor/DataBinding/BlockBindingsTest.php @@ -16,11 +16,13 @@ function get_query_var( string $var, $default = '' ): mixed { use PHPUnit\Framework\TestCase; use Mockery; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; +use RemoteDataBlocks\Editor\BlockManagement\ConfigRegistry; use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; use RemoteDataBlocks\Editor\DataBinding\BlockBindings; use RemoteDataBlocks\Tests\Mocks\MockQueryRunner; use RemoteDataBlocks\Tests\Mocks\MockWordPressFunctions; -use RemoteDataBlocks\Tests\Mocks\MockQueryContext; +use RemoteDataBlocks\Tests\Mocks\MockQuery; class BlockBindingsTest extends TestCase { private const MOCK_BLOCK_NAME = 'test/block'; @@ -35,7 +37,7 @@ class BlockBindingsTest extends TestCase { private const MOCK_OUTPUT_SCHEMA = [ 'is_collection' => false, - 'mappings' => [ + 'type' => [ 'output_field' => [ 'name' => 'Output Field', 'type' => 'string', @@ -72,7 +74,7 @@ public function test_execute_query_with_no_config(): void { * Mock the ConfigStore to return null. */ $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) + $mock_config_store->shouldReceive( 'get_block_configuration' ) ->once() ->with( self::MOCK_BLOCK_NAME ) ->andReturn( null ); @@ -109,11 +111,11 @@ public function test_execute_query_returns_query_results(): void { $mock_block_config = [ 'queries' => [ - '__DISPLAY__' => new MockQueryContext( - $mock_qr, - self::MOCK_INPUT_SCHEMA, - self::MOCK_OUTPUT_SCHEMA, - ), + ConfigRegistry::DISPLAY_QUERY_KEY => MockQuery::from_array( [ + 'input_schema' => self::MOCK_INPUT_SCHEMA, + 'output_schema' => self::MOCK_OUTPUT_SCHEMA, + 'query_runner' => $mock_qr, + ] ), ], ]; @@ -121,7 +123,7 @@ public function test_execute_query_returns_query_results(): void { * Mock the ConfigStore to return the block configuration. */ $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) + $mock_config_store->shouldReceive( 'get_block_configuration' ) ->once() ->with( self::MOCK_BLOCK_NAME ) ->andReturn( $mock_block_config ); @@ -137,7 +139,7 @@ public function test_execute_query_with_overrides(): void { /** * Set the query var to an override value. */ - MockWordPressFunctions::set_query_var( 'test_input_field', 'override_value' ); + MockWordPressFunctions::set_query_var( 'test_query_var', 'override_value' ); /** * Mock the QueryRunner to return a result. @@ -152,8 +154,8 @@ public function test_execute_query_with_overrides(): void { ], 'queryInputOverrides' => [ 'test_input_field' => [ - 'type' => 'url', - 'display' => '/test_input_field/{test_input_field}', + 'source' => 'test_query_var', + 'sourceType' => 'query_var', ], ], ]; @@ -162,27 +164,30 @@ public function test_execute_query_with_overrides(): void { 'test_input_field' => [ 'name' => 'Test Input Field', 'type' => 'string', - 'overrides' => [ - [ - 'target' => 'test_target', - 'type' => 'url', - ], - ], ], ]; $mock_block_config = [ 'queries' => [ - '__DISPLAY__' => new MockQueryContext( - $mock_qr, - $input_schema, - self::MOCK_OUTPUT_SCHEMA, - ), + ConfigRegistry::DISPLAY_QUERY_KEY => MockQuery::from_array( [ + 'input_schema' => $input_schema, + 'output_schema' => self::MOCK_OUTPUT_SCHEMA, + 'query_runner' => $mock_qr, + ] ), + 'query_input_overrides' => [ + [ + 'query' => ConfigRegistry::DISPLAY_QUERY_KEY, + 'source' => 'test_query_var', + 'source_type' => 'query_var', + 'target' => 'test_input_field', + 'target_type' => 'input_var', + ], + ], ], ]; $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) + $mock_config_store->shouldReceive( 'get_block_configuration' ) ->once() ->with( self::MOCK_BLOCK_NAME ) ->andReturn( $mock_block_config ); @@ -202,67 +207,17 @@ public function test_execute_query_with_overrides(): void { /** * @runInSeparateProcess */ - public function test_execute_query_with_query_input_transformations(): void { + public function test_execute_query_with_query_input_transformed_by_custom_query_runner(): void { /** * Mock the QueryRunner to return a result. */ - $mock_qr = new MockQueryRunner(); + $mock_qr = new class() extends MockQueryRunner { + public function execute( HttpQueryInterface $query, array $input_variables ): array { + $input_variables['test_input_field'] .= ' ' . $input_variables['another_input_field']; + return parent::execute( $query, $input_variables ); + } + }; $mock_qr->addResult( 'output_field', 'Test Output Value' ); - - $block_context = [ - 'blockName' => self::MOCK_BLOCK_NAME, - 'queryInput' => [ - 'test_input_field' => 'test_value', - ], - ]; - - $input_schema = [ - 'test_input_field' => [ - 'name' => 'Test Input Field', - 'type' => 'string', - 'transform' => function ( array $data ): string { - return $data['test_input_field'] . ' transformed'; - }, - ], - ]; - - $mock_block_config = [ - 'queries' => [ - '__DISPLAY__' => new MockQueryContext( - $mock_qr, - $input_schema, - self::MOCK_OUTPUT_SCHEMA, - ), - ], - ]; - - $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) - ->once() - ->with( self::MOCK_BLOCK_NAME ) - ->andReturn( $mock_block_config ); - - $query_results = BlockBindings::execute_query( $block_context, self::MOCK_OPERATION_NAME ); - $this->assertSame( $query_results, self::MOCK_OUTPUT_QUERY_RESULTS ); - - /** - * Assert that the query runner received the correct input after transformations were applied. - */ - $this->assertSame( $mock_qr->getLastExecuteCallInput(), [ - 'test_input_field' => 'test_value transformed', - ] ); - } - - /** - * @runInSeparateProcess - */ - public function test_execute_query_with_query_input_transformed_with_multiple_inputs(): void { - /** - * Mock the QueryRunner to return a result. - */ - $mock_qr = new MockQueryRunner(); - $mock_qr->addResult( 'output_field', 'Test Output Value' ); - $block_context = [ 'blockName' => self::MOCK_BLOCK_NAME, 'queryInput' => [ @@ -275,9 +230,6 @@ public function test_execute_query_with_query_input_transformed_with_multiple_in 'test_input_field' => [ 'name' => 'Test Input Field', 'type' => 'string', - 'transform' => function ( array $data ): string { - return $data['test_input_field'] . ' ' . $data['another_input_field']; - }, ], 'another_input_field' => [ 'name' => 'Another Input Field', @@ -287,16 +239,16 @@ public function test_execute_query_with_query_input_transformed_with_multiple_in $mock_block_config = [ 'queries' => [ - '__DISPLAY__' => new MockQueryContext( - $mock_qr, - $input_schema, - self::MOCK_OUTPUT_SCHEMA, - ), + ConfigRegistry::DISPLAY_QUERY_KEY => MockQuery::from_array( [ + 'input_schema' => $input_schema, + 'output_schema' => self::MOCK_OUTPUT_SCHEMA, + 'query_runner' => $mock_qr, + ] ), ], ]; $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) + $mock_config_store->shouldReceive( 'get_block_configuration' ) ->once() ->with( self::MOCK_BLOCK_NAME ) ->andReturn( $mock_block_config ); @@ -320,12 +272,20 @@ public function test_execute_query_with_query_input_transformations_and_override /** * Set the query var to an override value. */ - MockWordPressFunctions::set_query_var( 'test_input_field', 'override_value' ); + MockWordPressFunctions::set_query_var( 'test_query_var', 'override_value' ); /** * Mock the QueryRunner to return a result. */ - $mock_qr = new MockQueryRunner(); + /** + * Mock the QueryRunner to return a result. + */ + $mock_qr = new class() extends MockQueryRunner { + public function execute( HttpQueryInterface $query, array $input_variables ): array { + $input_variables['test_input_field'] .= ' transformed'; + return parent::execute( $query, $input_variables ); + } + }; $mock_qr->addResult( 'output_field', 'Test Output Value' ); $block_context = [ @@ -335,8 +295,8 @@ public function test_execute_query_with_query_input_transformations_and_override ], 'queryInputOverrides' => [ 'test_input_field' => [ - 'type' => 'url', - 'display' => '/test_input_field/{test_input_field}', + 'source' => 'test_query_var', + 'sourceType' => 'query_var', ], ], ]; @@ -345,24 +305,21 @@ public function test_execute_query_with_query_input_transformations_and_override 'test_input_field' => [ 'name' => 'Test Input Field', 'type' => 'string', - 'transform' => function ( array $data ): string { - return $data['test_input_field'] . ' transformed'; - }, ], ]; $mock_block_config = [ 'queries' => [ - '__DISPLAY__' => new MockQueryContext( - $mock_qr, - $input_schema, - self::MOCK_OUTPUT_SCHEMA, - ), + ConfigRegistry::DISPLAY_QUERY_KEY => MockQuery::from_array( [ + 'input_schema' => $input_schema, + 'output_schema' => self::MOCK_OUTPUT_SCHEMA, + 'query_runner' => $mock_qr, + ] ), ], ]; $mock_config_store = Mockery::namedMock( ConfigStore::class ); - $mock_config_store->shouldReceive( 'get_configuration' ) + $mock_config_store->shouldReceive( 'get_block_configuration' ) ->once() ->with( self::MOCK_BLOCK_NAME ) ->andReturn( $mock_block_config ); diff --git a/tests/inc/Functions/FunctionsTest.php b/tests/inc/Functions/FunctionsTest.php index ddff8a2a..295e95cb 100644 --- a/tests/inc/Functions/FunctionsTest.php +++ b/tests/inc/Functions/FunctionsTest.php @@ -4,37 +4,49 @@ use Psr\Log\LogLevel; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; use RemoteDataBlocks\Editor\BlockManagement\ConfigRegistry; use RemoteDataBlocks\Editor\BlockManagement\ConfigStore; use RemoteDataBlocks\Tests\Mocks\MockLogger; -use RemoteDataBlocks\Tests\Mocks\MockDataSource; -use RemoteDataBlocks\Tests\Mocks\MockValidator; +use RemoteDataBlocks\Tests\Mocks\MockQuery; use function register_remote_data_block; -use function register_remote_data_list_query; -use function register_remote_data_search_query; -use function register_remote_data_loop_block; class FunctionsTest extends TestCase { private MockLogger $mock_logger; - private MockDataSource $mock_data_source; + private MockQuery $mock_query; + private MockQuery $mock_list_query; + private MockQuery $mock_search_query; protected function setUp(): void { parent::setUp(); $this->mock_logger = new MockLogger(); - $this->mock_data_source = MockDataSource::from_array( MockDataSource::MOCK_CONFIG, new MockValidator() ); + $this->mock_query = MockQuery::from_array(); + $this->mock_list_query = MockQuery::from_array( [ + 'output_schema' => [ + 'is_collection' => true, + ], + ] ); + $this->mock_search_query = MockQuery::from_array( [ + 'input_schema' => [ + 'search_terms' => [ 'type' => 'string' ], + ], + ] ); + ConfigRegistry::init( $this->mock_logger ); } public function testRegisterBlock() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'Test Block', $query_context ); + register_remote_data_block( [ + 'title' => 'Test Block', + 'queries' => [ + 'display' => $this->mock_query, + ], + ] ); $block_name = 'remote-data-blocks/test-block'; $this->assertTrue( ConfigStore::is_registered_block( $block_name ) ); - $config = ConfigStore::get_configuration( $block_name ); + $config = ConfigStore::get_block_configuration( $block_name ); $this->assertIsArray( $config ); $this->assertSame( $block_name, $config['name'] ); $this->assertSame( 'Test Block', $config['title'] ); @@ -42,73 +54,58 @@ public function testRegisterBlock() { } public function testRegisterLoopBlock() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_loop_block( 'Loop Block', $query_context ); + register_remote_data_block( [ + 'title' => 'Loop Block', + 'queries' => [ + 'display' => $this->mock_list_query, + ], + 'loop' => true, + ] ); $block_name = 'remote-data-blocks/loop-block'; $this->assertTrue( ConfigStore::is_registered_block( $block_name ) ); - $config = ConfigStore::get_configuration( $block_name ); + $config = ConfigStore::get_block_configuration( $block_name ); $this->assertIsArray( $config ); $this->assertTrue( $config['loop'] ); } - public function testRegisterQuery() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'Query Block', $query_context ); - - $additional_query = new HttpQueryContext( $this->mock_data_source ); - ConfigRegistry::register_query( 'Query Block', $additional_query ); - - $block_name = 'remote-data-blocks/query-block'; - $config = ConfigStore::get_configuration( $block_name ); - $this->assertArrayHasKey( get_class( $additional_query ), $config['queries'] ); - } - public function testRegisterListQuery() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'List Block', $query_context ); - - $list_query = new HttpQueryContext( - $this->mock_data_source, - [], - [ 'mappings' => [ 'test' => 'test' ] ] - ); - register_remote_data_list_query( 'List Block', $list_query ); - - $block_name = 'remote-data-blocks/list-block'; - $config = ConfigStore::get_configuration( $block_name ); - $this->assertSame( 'list', $config['selectors'][0]['type'] ); + register_remote_data_block( [ + 'title' => 'Test Block with List Query', + 'queries' => [ + 'display' => $this->mock_query, + 'list' => $this->mock_list_query, + ], + ] ); + + $block_name = 'remote-data-blocks/test-block-with-list-query'; + $config = ConfigStore::get_block_configuration( $block_name ); + $this->assertSame( 'list', $config['selectors'][0]['type'] ?? null ); } public function testRegisterSearchQuery() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'Search Block', $query_context ); - - $search_query = new HttpQueryContext( - $this->mock_data_source, - [ 'search_terms' => [ 'type' => 'string' ] ], - [ 'mappings' => [ 'test' => 'test' ] ] - ); - register_remote_data_search_query( 'Search Block', $search_query ); - - $block_name = 'remote-data-blocks/search-block'; - $config = ConfigStore::get_configuration( $block_name ); - $this->assertSame( 'search', $config['selectors'][0]['type'] ); - } - - public function testGetBlockNames() { - register_remote_data_block( 'Block One', new HttpQueryContext( $this->mock_data_source ) ); - register_remote_data_block( 'Block Two', new HttpQueryContext( $this->mock_data_source ) ); - - $block_names = ConfigStore::get_block_names(); - $this->assertCount( 2, $block_names ); - $this->assertContains( 'remote-data-blocks/block-one', $block_names ); - $this->assertContains( 'remote-data-blocks/block-two', $block_names ); + register_remote_data_block( [ + 'title' => 'Test Block with Search Query', + 'queries' => [ + 'display' => $this->mock_query, + 'search' => $this->mock_search_query, + ], + ] ); + + $block_name = 'remote-data-blocks/test-block-with-search-query'; + $config = ConfigStore::get_block_configuration( $block_name ); + $this->assertSame( 'search', $config['selectors'][0]['type'] ?? null ); } public function testIsRegisteredBlockReturnsTrueForRegisteredBlock() { - register_remote_data_block( 'Some Slick Block', new HttpQueryContext( $this->mock_data_source ) ); + register_remote_data_block( [ + 'title' => 'Some Slick Block', + 'queries' => [ + 'display' => $this->mock_query, + ], + ] ); + $this->assertTrue( ConfigStore::is_registered_block( 'remote-data-blocks/some-slick-block' ) ); } @@ -117,16 +114,25 @@ public function testIsRegisteredBlockReturnsFalseWhenNoConfigurations() { } public function testGetConfigurationForNonexistentBlock() { - $this->assertNull( ConfigStore::get_configuration( 'nonexistent' ) ); + $this->assertNull( ConfigStore::get_block_configuration( 'nonexistent' ) ); $this->assertTrue( $this->mock_logger->hasLoggedLevel( LogLevel::ERROR ) ); $error_logs = $this->mock_logger->getLogsByLevel( LogLevel::ERROR ); $this->assertStringContainsString( 'not been registered', $error_logs[0]['message'] ); } public function testRegisterDuplicateBlock() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'Duplicate Block', $query_context ); - register_remote_data_block( 'Duplicate Block', $query_context ); + register_remote_data_block( [ + 'title' => 'Duplicate Block', + 'queries' => [ + 'display' => $this->mock_query, + ], + ] ); + register_remote_data_block( [ + 'title' => 'Duplicate Block', + 'queries' => [ + 'display' => $this->mock_query, + ], + ] ); $this->assertTrue( $this->mock_logger->hasLoggedLevel( LogLevel::ERROR ) ); $error_logs = $this->mock_logger->getLogsByLevel( LogLevel::ERROR ); @@ -134,11 +140,13 @@ public function testRegisterDuplicateBlock() { } public function testRegisterSearchQueryWithoutSearchTerms() { - $query_context = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_block( 'Invalid Search Block', $query_context ); - - $search_query = new HttpQueryContext( $this->mock_data_source ); - register_remote_data_search_query( 'Invalid Search Block', $search_query ); + register_remote_data_block( [ + 'title' => 'Invalid Search Block', + 'queries' => [ + 'display' => $this->mock_query, + 'search' => $this->mock_query, + ], + ] ); $this->assertTrue( $this->mock_logger->hasLoggedLevel( LogLevel::ERROR ) ); $error_logs = $this->mock_logger->getLogsByLevel( LogLevel::ERROR ); diff --git a/tests/inc/Integrations/Airtable/AirtableDataSourceTest.php b/tests/inc/Integrations/Airtable/AirtableDataSourceTest.php index c36cdfb8..99e51af4 100644 --- a/tests/inc/Integrations/Airtable/AirtableDataSourceTest.php +++ b/tests/inc/Integrations/Airtable/AirtableDataSourceTest.php @@ -11,39 +11,27 @@ class AirtableDataSourceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->data_source = AirtableDataSource::create( - 'test_access_token', - 'test_base_id', - [], - 'Test Airtable Base' - ); + $this->data_source = AirtableDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'access_token' => 'test_access_token', + 'display_name' => 'Airtable Source', + 'base' => [ + 'id' => 'test_base_id', + 'name' => 'Test Airtable Base', + ], + 'tables' => [], + ], + ] ); } public function test_get_display_name(): void { $this->assertSame( - 'Airtable (Test Airtable Base)', + 'Airtable Source', $this->data_source->get_display_name() ); } - public function test_get_display_name_with_base_name_override(): void { - $data_source = AirtableDataSource::from_array([ - 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'access_token' => 'test_access_token', - 'base' => [ - 'id' => 'test_base_id', - 'name' => 'Test Base Name', - ], - 'tables' => [], - 'display_name' => 'Test Base Name', - ]); - - $this->assertSame( - 'Airtable (Test Base Name)', - $data_source->get_display_name() - ); - } - public function test_get_endpoint(): void { $this->assertSame( 'https://api.airtable.com/v0/test_base_id', @@ -61,15 +49,8 @@ public function test_get_request_headers(): void { } public function test_create(): void { - $data_source = AirtableDataSource::create( - 'new_access_token', - 'new_base_id', - [], - 'New Airtable Base' - ); - - $this->assertInstanceOf( AirtableDataSource::class, $data_source ); - $this->assertSame( 'Airtable (New Airtable Base)', $data_source->get_display_name() ); - $this->assertSame( 'https://api.airtable.com/v0/new_base_id', $data_source->get_endpoint() ); + $this->assertInstanceOf( AirtableDataSource::class, $this->data_source ); + $this->assertSame( 'Airtable Source', $this->data_source->get_display_name() ); + $this->assertSame( 'https://api.airtable.com/v0/test_base_id', $this->data_source->get_endpoint() ); } } diff --git a/tests/inc/Integrations/Google/Sheets/GoogleSheetsDataSourceTest.php b/tests/inc/Integrations/Google/Sheets/GoogleSheetsDataSourceTest.php index ba32b304..7c1b483b 100644 --- a/tests/inc/Integrations/Google/Sheets/GoogleSheetsDataSourceTest.php +++ b/tests/inc/Integrations/Google/Sheets/GoogleSheetsDataSourceTest.php @@ -31,16 +31,26 @@ class GoogleSheetsDataSourceTest extends TestCase { protected function setUp(): void { parent::setUp(); - $this->data_source = GoogleSheetsDataSource::create( - self::MOCK_CREDENTIALS, - 'test_spreadsheet_id', - 'Test Display Name' - ); + $this->data_source = GoogleSheetsDataSource::from_array( [ + 'service_config' => [ + '__version' => 1, + 'display_name' => 'Google Sheets Source', + 'credentials' => self::MOCK_CREDENTIALS, + 'spreadsheet' => [ + 'id' => 'test_spreadsheet_id', + 'name' => 'Test Spreadsheet Name', + ], + 'sheet' => [ + 'id' => 1, + 'name' => 'Test Sheet Name', + ], + ], + ] ); } public function test_get_display_name(): void { $this->assertSame( - 'Google Sheets: Test Display Name', + 'Google Sheets Source', $this->data_source->get_display_name() ); } @@ -79,14 +89,8 @@ public function test_get_request_headers(): void { } public function test_create(): void { - $data_source = GoogleSheetsDataSource::create( - self::MOCK_CREDENTIALS, - 'test_spreadsheet_id', - 'New Google Sheet' - ); - - $this->assertInstanceOf( GoogleSheetsDataSource::class, $data_source ); - $this->assertSame( 'Google Sheets: New Google Sheet', $data_source->get_display_name() ); - $this->assertSame( 'https://sheets.googleapis.com/v4/spreadsheets/test_spreadsheet_id', $data_source->get_endpoint() ); + $this->assertInstanceOf( GoogleSheetsDataSource::class, $this->data_source ); + $this->assertSame( 'Google Sheets Source', $this->data_source->get_display_name() ); + $this->assertSame( 'https://sheets.googleapis.com/v4/spreadsheets/test_spreadsheet_id', $this->data_source->get_endpoint() ); } } diff --git a/tests/inc/Integrations/VipBlockDataApi/VipBlockDataApiTest.php b/tests/inc/Integrations/VipBlockDataApi/VipBlockDataApiTest.php index 65c2b1eb..a06fe626 100644 --- a/tests/inc/Integrations/VipBlockDataApi/VipBlockDataApiTest.php +++ b/tests/inc/Integrations/VipBlockDataApi/VipBlockDataApiTest.php @@ -3,26 +3,13 @@ namespace RemoteDataBlocks\Tests\Integrations\VipBlockDataApi; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\QueryContext\HttpQueryContext; -use RemoteDataBlocks\Config\QueryRunner\QueryRunnerInterface; use RemoteDataBlocks\Editor\BlockManagement\ConfigRegistry; use RemoteDataBlocks\Integrations\VipBlockDataApi\VipBlockDataApi; +use RemoteDataBlocks\Tests\Mocks\MockQuery; use RemoteDataBlocks\Tests\Mocks\MockQueryRunner; -use RemoteDataBlocks\Tests\Mocks\MockDataSource; -use RemoteDataBlocks\Tests\Mocks\MockValidator; use function register_remote_data_block; -class TestQueryContext extends HttpQueryContext { - public function __construct( private QueryRunnerInterface $mock_qr ) { - parent::__construct( MockDataSource::from_array( MockDataSource::MOCK_CONFIG, new MockValidator() ) ); - } - - public function get_query_runner(): QueryRunnerInterface { - return $this->mock_qr; - } -} - class VipBlockDataApiTest extends TestCase { private static $sourced_block1 = [ 'name' => 'remote-data-blocks/events', @@ -160,10 +147,15 @@ public function testResolveRemoteDataSimple() { $mock_qr->addResult( 'title', $expected1 ); $mock_qr->addResult( 'location', $expected2 ); - $mock_query_context = new TestQueryContext( $mock_qr ); - register_remote_data_block( 'Events', $mock_query_context ); + $mock_query = MockQuery::from_array( [ 'query_runner' => $mock_qr ] ); + register_remote_data_block( [ + 'title' => 'Events', + 'queries' => [ + 'display' => $mock_query, + ], + ] ); - $result = VipBlockDataApi::resolve_remote_data( self::$sourced_block1, 'remote-data-blocks/events', 12, self::$parsed_block1, $mock_qr ); + $result = VipBlockDataApi::resolve_remote_data( self::$sourced_block1, 'remote-data-blocks/events', 12, self::$parsed_block1 ); $this->assertSame( $expected1, $result['innerBlocks'][0]['attributes']['content'] ); $this->assertSame( $expected2, $result['innerBlocks'][1]['attributes']['content'] ); } @@ -179,10 +171,15 @@ public function testResolveRemoteDataFallsBackToDbOnQuery() { $mock_qr->addResult( 'title', 'Happy happy hour! No networking!' ); $mock_qr->addResult( 'location', new \WP_Error( 'rdb-uh-oh', 'uh-oh!' ) ); - $mock_query_context = new TestQueryContext( $mock_qr ); - register_remote_data_block( 'Events', $mock_query_context ); + $mock_query = MockQuery::from_array( [ 'query_runner' => $mock_qr ] ); + register_remote_data_block( [ + 'title' => 'Events', + 'queries' => [ + 'display' => $mock_query, + ], + ] ); - $result = VipBlockDataApi::resolve_remote_data( self::$sourced_block1, 'remote-data-blocks/events', 12, self::$parsed_block1, $mock_qr ); + $result = VipBlockDataApi::resolve_remote_data( self::$sourced_block1, 'remote-data-blocks/events', 12, self::$parsed_block1 ); $this->assertSame( [ diff --git a/tests/inc/Mocks/MockDataSource.php b/tests/inc/Mocks/MockDataSource.php index a6af2560..d43b12fb 100644 --- a/tests/inc/Mocks/MockDataSource.php +++ b/tests/inc/Mocks/MockDataSource.php @@ -3,50 +3,30 @@ namespace RemoteDataBlocks\Tests\Mocks; use RemoteDataBlocks\Config\DataSource\HttpDataSource; +use RemoteDataBlocks\Tests\Mocks\MockValidator; +use RemoteDataBlocks\Validation\ValidatorInterface; use WP_Error; class MockDataSource extends HttpDataSource { - private $endpoint = 'https://example.com/api'; - private $headers = [ 'Content-Type' => 'application/json' ]; - public const MOCK_CONFIG = [ - 'uuid' => 'e3458c42-4cf4-4214-aaf6-3628e33ed07a', 'service' => 'mock', - 'api_key' => '1234567890', - ]; - - protected const SERVICE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'api_key' => [ - 'type' => 'string', + 'service_config' => [ + 'display_name' => 'Mock Data Source', + 'endpoint' => 'https://example.com/api', + 'request_headers' => [ + 'Content-Type' => 'application/json', ], ], ]; - public function get_display_name(): string { - return 'Mock Data Source'; - } - - public function get_endpoint(): string { - return $this->endpoint; - } - - public function get_request_headers(): array|WP_Error { - return $this->headers; + public static function from_array( ?array $config = self::MOCK_CONFIG, ?ValidatorInterface $validator = null ): self|WP_Error { + return parent::from_array( $config, $validator ?? new MockValidator() ); } /** * Override the endpoint. */ public function set_endpoint( string $endpoint ): void { - $this->endpoint = $endpoint; - } - - /** - * Override the headers. - */ - public function set_headers( array $headers ): void { - $this->headers = $headers; + $this->config['endpoint'] = $endpoint; } } diff --git a/tests/inc/Mocks/MockQuery.php b/tests/inc/Mocks/MockQuery.php new file mode 100644 index 00000000..df38fe73 --- /dev/null +++ b/tests/inc/Mocks/MockQuery.php @@ -0,0 +1,47 @@ + $config['data_source'] ?? MockDataSource::from_array(), + 'display_name' => 'Mock Query', + 'input_schema' => $config['input_schema'] ?? [], + 'output_schema' => $config['output_schema'] ?? [ 'type' => 'string' ], + 'query_runner' => $config['query_runner'] ?? new MockQueryRunner(), + ], $validator ?? new MockValidator() ); + } + + public function preprocess_response( mixed $response_data, array $input_variables ): mixed { + if ( null !== $this->response_data ) { + return $this->response_data; + } + + return $response_data; + } + + public function set_output_schema( array $output_schema ): void { + $this->config['output_schema'] = $output_schema; + } + + public function set_request_method( string $method ): void { + $this->config['request_method'] = $method; + } + + public function set_request_body( array $body ): void { + $this->config['request_body'] = $body; + } + + public function set_response_data( mixed $data ): void { + $this->response_data = $data; + } +} diff --git a/tests/inc/Mocks/MockQueryContext.php b/tests/inc/Mocks/MockQueryContext.php deleted file mode 100644 index 0b9cc72d..00000000 --- a/tests/inc/Mocks/MockQueryContext.php +++ /dev/null @@ -1,26 +0,0 @@ -mock_qr; - } -} diff --git a/tests/inc/Mocks/MockQueryRunner.php b/tests/inc/Mocks/MockQueryRunner.php index 5c4ade77..b91abc2c 100644 --- a/tests/inc/Mocks/MockQueryRunner.php +++ b/tests/inc/Mocks/MockQueryRunner.php @@ -2,14 +2,19 @@ namespace RemoteDataBlocks\Tests\Mocks; +use RemoteDataBlocks\Config\Query\HttpQueryInterface; use RemoteDataBlocks\Config\QueryRunner\QueryRunnerInterface; +use WP_Error; class MockQueryRunner implements QueryRunnerInterface { - private $query_results = []; - private $execute_call_inputs = []; + /** @var array */ + private array $query_results = []; - public function addResult( $field, $result ) { - if ( $result instanceof \WP_Error ) { + /** @var array */ + private array $execute_call_inputs = []; + + public function addResult( string $field, mixed $result ): void { + if ( $result instanceof WP_Error ) { array_push( $this->query_results, $result ); return; } @@ -26,9 +31,9 @@ public function addResult( $field, $result ) { ] ); } - public function execute( array $input_variables ): array|\WP_Error { + public function execute( HttpQueryInterface $query, array $input_variables ): array|WP_Error { array_push( $this->execute_call_inputs, $input_variables ); - return array_shift( $this->query_results ); + return array_shift( $this->query_results ) ?? new WP_Error( 'no-results', 'No results available.' ); } public function getLastExecuteCallInput(): array|null { diff --git a/tests/inc/Sanitization/SanitizerTest.php b/tests/inc/Sanitization/SanitizerTest.php index b7212d17..98d0e61d 100644 --- a/tests/inc/Sanitization/SanitizerTest.php +++ b/tests/inc/Sanitization/SanitizerTest.php @@ -4,81 +4,92 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\Sanitization\Sanitizer; +use RemoteDataBlocks\Validation\Types; class SanitizerTest extends TestCase { public function test_sanitize_string() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - ], - ]; - $data = [ 'name' => ' John Doe ' ]; - + $schema = Types::object( [ + 'name' => Types::string(), + ] ); + $sanitizer = new Sanitizer( $schema ); - $result = $sanitizer->sanitize( $data ); - + $result = $sanitizer->sanitize( [ 'name' => ' John Doe ' ] ); + + $this->assertSame( 'John Doe', $result['name'] ); + + // Takes the first element of the array. + $result = $sanitizer->sanitize( [ 'name' => [ [ 'John Doe' ], 'Jane Doe', 33 ] ] ); + $this->assertSame( 'John Doe', $result['name'] ); } public function test_sanitize_integer() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'age' => [ 'type' => 'integer' ], - ], - ]; + $schema = Types::object( [ + 'age' => Types::integer(), + ] ); $data = [ 'age' => '25' ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $this->assertSame( 25, $result['age'] ); } public function test_sanitize_boolean() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'is_active' => [ 'type' => 'boolean' ], - ], - ]; + $schema = Types::object( [ + 'is_active' => Types::boolean(), + ] ); $data = [ 'is_active' => 1 ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $this->assertSame( true, $result['is_active'] ); } - public function test_sanitize_array() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'tags' => [ 'type' => 'array' ], - ], + public function test_sanitize_any() { + $schema = Types::object( [ + 'one' => Types::any(), + 'two' => Types::any(), + 'three' => Types::any(), + 'four' => Types::any(), + 'five' => Types::any(), + ] ); + $data = [ + 'one' => 'string', + 'two' => 123, + 'three' => true, + 'four' => [ 'array' ], + 'five' => null, ]; + + $sanitizer = new Sanitizer( $schema ); + $result = $sanitizer->sanitize( $data ); + + $this->assertSame( $data, $result ); + } + + public function test_sanitize_array() { + $schema = Types::object( [ + 'tags' => Types::list_of( Types::string() ), + ] ); $data = [ 'tags' => [ 'php', ' javascript ', 'python ' ] ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $this->assertSame( [ 'php', 'javascript', 'python' ], $result['tags'] ); } public function test_sanitize_nested_array() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'users' => [ - 'type' => 'array', - 'items' => [ - 'name' => [ 'type' => 'string' ], - 'age' => [ 'type' => 'integer' ], - ], - ], - ], - ]; + $schema = Types::object( [ + 'users' => Types::list_of( + Types::object( [ + 'name' => Types::string(), + 'age' => Types::integer(), + ] ) + ), + ] ); $data = [ 'users' => [ [ @@ -91,10 +102,10 @@ public function test_sanitize_nested_array() { ], ], ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $expected = [ 'users' => [ [ @@ -111,21 +122,14 @@ public function test_sanitize_nested_array() { } public function test_sanitize_nested_array_of_objects() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'users' => [ - 'type' => 'array', - 'items' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - 'age' => [ 'type' => 'integer' ], - ], - ], - ], - ], - ]; + $schema = Types::object( [ + 'users' => Types::list_of( + Types::object( [ + 'name' => Types::string(), + 'age' => Types::integer(), + ] ) + ), + ] ); $data = [ 'users' => [ [ @@ -139,10 +143,10 @@ public function test_sanitize_nested_array_of_objects() { ], ], ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $expected = [ 'users' => [ [ @@ -160,28 +164,22 @@ public function test_sanitize_nested_array_of_objects() { public function test_sanitize_object() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'user' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - 'age' => [ 'type' => 'integer' ], - ], - ], - ], - ]; + $schema = Types::object( [ + 'user' => Types::object( [ + 'name' => Types::string(), + 'age' => Types::integer(), + ] ), + ] ); $data = [ 'user' => [ 'name' => ' John Doe ', 'age' => '30', ], ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $expected = [ 'user' => [ 'name' => 'John Doe', @@ -191,65 +189,62 @@ public function test_sanitize_object() { $this->assertSame( $expected, $result ); } - public function test_sanitize_with_custom_sanitizer() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'email' => [ - 'type' => 'string', - 'sanitize' => function ( $value ) { - return strtolower( trim( $value ) ); - }, - ], + public function test_sanitize_record() { + $schema = Types::record( + Types::string(), + Types::object( [ + 'name' => Types::string(), + 'age' => Types::integer(), + ] ), + ); + $data = [ + '123' => [ + 'name' => ' John Doe ', + 'age' => '30', ], ]; - $data = [ 'email' => ' User@Example.com ' ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - - $this->assertSame( 'user@example.com', $result['email'] ); - } - public function test_sanitize_ignores_undefined_fields() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], + $expected = [ + '123' => [ + 'name' => 'John Doe', + 'age' => 30, ], ]; + $this->assertSame( $expected, $result ); + } + + public function test_sanitize_removes_undefined_fields() { + $schema = Types::object( [ + 'name' => Types::string(), + ] ); $data = [ 'name' => 'John Doe', 'age' => 30, ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $this->assertArrayHasKey( 'name', $result ); $this->assertArrayNotHasKey( 'age', $result ); } public function test_sanitize_complex_nested_structure() { - $schema = [ - 'type' => 'object', - 'properties' => [ - 'company' => [ - 'type' => 'object', - 'properties' => [ - 'name' => [ 'type' => 'string' ], - 'employees' => [ - 'type' => 'array', - 'items' => [ - 'name' => [ 'type' => 'string' ], - 'position' => [ 'type' => 'string' ], - 'skills' => [ 'type' => 'array' ], - ], - ], - ], - ], - ], - ]; + $schema = Types::object( [ + 'company' => Types::object( [ + 'name' => Types::string(), + 'employees' => Types::list_of( + Types::object( [ + 'name' => Types::string(), + 'position' => Types::string(), + 'skills' => Types::list_of( Types::string() ), + ] ) + ), + ] ), + ] ); $data = [ 'company' => [ 'name' => ' Acme Corp ', @@ -267,10 +262,10 @@ public function test_sanitize_complex_nested_structure() { ], ], ]; - + $sanitizer = new Sanitizer( $schema ); $result = $sanitizer->sanitize( $data ); - + $expected = [ 'company' => [ 'name' => 'Acme Corp', @@ -290,4 +285,16 @@ public function test_sanitize_complex_nested_structure() { ]; $this->assertSame( $expected, $result ); } + + public function test_skip_sanitize_string() { + $schema = Types::object( [ + 'password' => Types::skip_sanitize( Types::string() ), + ] ); + $data = [ 'password' => ' John Doe ' ]; + + $sanitizer = new Sanitizer( $schema ); + $result = $sanitizer->sanitize( $data ); + + $this->assertSame( ' John Doe ', $result['password'] ); + } } diff --git a/tests/inc/Validation/ValidatorTest.php b/tests/inc/Validation/ValidatorTest.php index 45b3cd1d..59a0b03e 100644 --- a/tests/inc/Validation/ValidatorTest.php +++ b/tests/inc/Validation/ValidatorTest.php @@ -3,297 +3,422 @@ namespace RemoteDataBlocks\Tests\Validation; use PHPUnit\Framework\TestCase; +use RemoteDataBlocks\Validation\Types; use RemoteDataBlocks\Validation\Validator; +use stdClass; use WP_Error; class ValidatorTest extends TestCase { - const AIRTABLE_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'access_token' => [ 'type' => 'string' ], - 'base' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'string' ], - 'name' => [ 'type' => 'string' ], - ], - ], - 'tables' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'string' ], - 'name' => [ 'type' => 'string' ], - ], - ], - ], - ]; - - const SHOPIFY_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'access_token' => [ 'type' => 'string' ], - 'store_name' => [ 'type' => 'string' ], - ], - ]; - - const GOOGLE_SHEETS_SCHEMA = [ - 'type' => 'object', - 'properties' => [ - 'credentials' => [ - 'type' => 'object', - 'properties' => [ - 'type' => [ 'type' => 'string' ], - 'project_id' => [ 'type' => 'string' ], - 'private_key_id' => [ 'type' => 'string' ], - 'private_key' => [ 'type' => 'string' ], - 'client_email' => [ - 'type' => 'string', - 'callback' => 'is_email', - 'sanitize' => 'sanitize_email', - ], - 'client_id' => [ 'type' => 'string' ], - 'auth_uri' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'token_uri' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'auth_provider_x509_cert_url' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'client_x509_cert_url' => [ - 'type' => 'string', - 'sanitize' => 'sanitize_url', - ], - 'universe_domain' => [ 'type' => 'string' ], - ], - ], - 'spreadsheet' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'string' ], - 'name' => [ 'type' => 'string' ], - ], - ], - 'sheet' => [ - 'type' => 'object', - 'properties' => [ - 'id' => [ 'type' => 'integer' ], - 'name' => [ 'type' => 'string' ], - ], - ], - ], - ]; + public function testValidPrimitiveTypes(): void { + $schema = Types::object( [ + 'boolean' => Types::boolean(), + 'integer' => Types::integer(), + 'null' => Types::null(), + 'number' => Types::number(), + 'string' => Types::string(), + + 'email_address' => Types::email_address(), + 'html' => Types::html(), + 'id' => Types::id(), + 'image_alt' => Types::image_alt(), + 'image_url' => Types::image_url(), + 'json_path' => Types::json_path(), + 'markdown' => Types::html(), + 'url' => Types::url(), + 'uuid' => Types::uuid(), + ] ); + + $validator = new Validator( $schema ); - public function test_validate_airtable_source_with_valid_input() { - $valid_source = [ + $this->assertTrue( $validator->validate( [ + 'boolean' => true, + 'integer' => 42, + 'null' => null, + 'number' => 3.14, + 'string' => 'foo', + + 'email_address' => 'me@example.com', + 'html' => '

Hello, world!

', + 'id' => '123', + 'image_alt' => 'A tree', + 'image_url' => 'https://example.com/image.jpg', + 'json_path' => '$.foo.bar', + 'markdown' => '# Hello, world!', + 'url' => 'https://example.com/foo', 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'access_token' => 'valid_token', - 'service' => 'airtable', - 'base' => [ - 'id' => 'base_id', - 'name' => 'Base Name', - ], - 'tables' => [ - 'id' => 'table_id', - 'name' => 'Table Name', - ], + ] ) ); + } + + public function testInvalidBooleans(): void { + $invalid_booleans = [ + null, + 42, + 3.14, + '', + 'foo', + [], + (object) [], ]; - $validator = new Validator( self::AIRTABLE_SCHEMA ); - $this->assertTrue( $validator->validate( $valid_source ) ); + $validator = new Validator( Types::boolean() ); + + foreach ( $invalid_booleans as $invalid_boolean ) { + $result = $validator->validate( $invalid_boolean ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a boolean:', $result->get_error_message() ); + } } - public function test_validate_airtable_source_with_invalid_input() { - $invalid_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'airtable', + public function testInvalidIntegers(): void { + $invalid_integers = [ + null, + true, + 3.14, + '', + 'foo', + [], + (object) [], ]; - $validator = new Validator( self::AIRTABLE_SCHEMA ); - $this->assertInstanceOf( WP_Error::class, $validator->validate( $invalid_source ) ); + $validator = new Validator( Types::integer() ); + + foreach ( $invalid_integers as $invalid_integer ) { + $result = $validator->validate( $invalid_integer ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a integer:', $result->get_error_message() ); + } } - public function test_validate_shopify_source_with_valid_input() { - $valid_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'access_token' => 'valid_token', - 'service' => 'shopify', - 'store_name' => 'mystore.myshopify.com', + public function testInvalidNulls(): void { + $invalid_nulls = [ + true, + 42, + 3.14, + '', + 'foo', + [], + (object) [], ]; - - $validator = new Validator( self::SHOPIFY_SCHEMA ); - $this->assertTrue( $validator->validate( $valid_source ) ); + + $validator = new Validator( Types::null() ); + + foreach ( $invalid_nulls as $invalid_null ) { + $result = $validator->validate( $invalid_null ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a null:', $result->get_error_message() ); + } } - public function test_validate_shopify_source_with_invalid_input() { - $invalid_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'shopify', + public function testInvalidNumbers(): void { + $invalid_numbers = [ + null, + true, + '', + 'foo', + [], + (object) [], ]; - $validator = new Validator( self::SHOPIFY_SCHEMA ); - $this->assertInstanceOf( WP_Error::class, $validator->validate( $invalid_source ) ); + $validator = new Validator( Types::number() ); + + foreach ( $invalid_numbers as $invalid_number ) { + $result = $validator->validate( $invalid_number ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a number:', $result->get_error_message() ); + } } - - public function test_validate_google_sheets_source_with_valid_input() { - $valid_credentials = [ - 'type' => 'service_account', - 'project_id' => 'test-project', - 'private_key_id' => '1234567890abcdef', - 'private_key' => '-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQC7/jHh2Wo0zkA5\n-----END PRIVATE KEY-----\n', - 'client_email' => 'test@test-project.iam.gserviceaccount.com', - 'client_id' => '123456789012345678901', - 'auth_uri' => 'https://accounts.google.com/o/oauth2/auth', - 'token_uri' => 'https://oauth2.googleapis.com/token', - 'auth_provider_x509_cert_url' => 'https://www.googleapis.com/oauth2/v1/certs', - 'client_x509_cert_url' => 'https://www.googleapis.com/robot/v1/metadata/x509/test%40test-project.iam.gserviceaccount.com', - 'universe_domain' => 'googleapis.com', - ]; - $valid_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'google-sheets', - 'credentials' => $valid_credentials, - 'spreadsheet' => [ - 'id' => 'spreadsheet_id', - 'name' => 'Spreadsheet Name', - ], - 'sheet' => [ - 'id' => 0, - 'name' => 'Sheet Name', - ], + public function testInvalidStrings(): void { + $invalid_strings = [ + null, + true, + 42, + 3.14, + [ 'foo' ], + (object) [], ]; - $validator = new Validator( self::GOOGLE_SHEETS_SCHEMA ); - $this->assertTrue( $validator->validate( $valid_source ) ); + $validator = new Validator( Types::string() ); + + foreach ( $invalid_strings as $invalid_string ) { + $result = $validator->validate( $invalid_string ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a string:', $result->get_error_message() ); + } } - public function test_validate_google_sheets_source_with_invalid_input() { - $invalid_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'google-sheets', - 'credentials' => [ - 'type' => 'service_account', - ], + public function testInvalidEmailAddresses(): void { + $invalid_email_addresses = [ + null, + true, + 42, + 3.14, + '', + 'foo', + [], + (object) [], + 'me@example', + '@example.com', + 'me@.com', + 'me@example.', + 'me@.example.com', + 'me@ex ample.com', + 'me@ex' . str_repeat( 'a', 64 ) . '.com', ]; - $validator = new Validator( self::GOOGLE_SHEETS_SCHEMA ); - $result = $validator->validate( $invalid_source ); + $validator = new Validator( Types::email_address() ); + + foreach ( $invalid_email_addresses as $invalid_email_address ) { + $result = $validator->validate( $invalid_email_address ); + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertStringStartsWith( 'Value must be a email_address:', $result->get_error_message() ); + } + } + + // TODO additional invalid primitive tests + + public function testCallable(): void { + $schema = Types::callable(); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( 'is_string' ) ); + $this->assertTrue( $validator->validate( function (): string { + return 'foo'; + } ) ); + $this->assertTrue( $validator->validate( [ $this, 'testCallable' ] ) ); + + $result = $validator->validate( 'foo' ); $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertSame( 'missing_field', $result->get_error_code() ); + $this->assertSame( 'Value must be callable: foo', $result->get_error_message() ); } - public function test_validate_nested_array_with_valid_input() { - $valid_nested_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'valid-nested-service', - 'whatever' => [ - 'level1' => [ - 'level2' => [ - 'key1' => 'value1', - 'key2' => 42, - ], - 'simple_array' => [ 'item1', 'item2', 'item3' ], - ], - 'boolean_field' => true, - 'enum_field' => 'option2', - ], - ]; + public function testConst(): void { + $schema = Types::const( 'foo' ); - $schema = [ - 'type' => 'object', - 'properties' => [ - 'uuid' => [ 'type' => 'string' ], - 'service' => [ 'type' => 'string' ], - 'whatever' => [ - 'type' => 'object', - 'properties' => [ - 'level1' => [ - 'type' => 'object', - 'properties' => [ - 'level2' => [ - 'type' => 'object', - 'properties' => [ - 'key1' => [ 'type' => 'string' ], - 'key2' => [ 'type' => 'integer' ], - ], - ], - 'simple_array' => [ - 'type' => 'array', - 'items' => [ 'type' => 'string' ], - ], - ], - ], - 'boolean_field' => [ 'type' => 'boolean' ], - 'enum_field' => [ - 'type' => 'string', - 'enum' => [ 'option1', 'option2', 'option3' ], - ], - ], - ], - ], - ]; + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( 'foo' ) ); + + $result = $validator->validate( 'bar' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be the constant: bar', $result->get_error_message() ); + } + + public function testEnum(): void { + $schema = Types::enum( 'foo', 'bar' ); $validator = new Validator( $schema ); - $this->assertTrue( $validator->validate( $valid_nested_source ) ); + + $this->assertTrue( $validator->validate( 'foo' ) ); + $this->assertTrue( $validator->validate( 'bar' ) ); + + $result = $validator->validate( 'baz' ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be one of the enumerated values: baz', $result->get_error_message() ); } - public function test_validate_nested_array_with_invalid_input() { - $invalid_nested_source = [ - 'uuid' => '123e4567-e89b-12d3-a456-426614174000', - 'service' => 'invalid-nested-service', - 'whatever' => [ - 'level1' => [ - 'level2' => [ - 'key1' => 'value1', - 'key2' => 'not_an_integer', // This should be an integer + public function testInstanceOf(): void { + $schema = Types::instance_of( self::class ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( $this ) ); + + $result = $validator->validate( new stdClass() ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be an instance of the specified class: {}', $result->get_error_message() ); + } + + public function testOneOf(): void { + $schema = Types::one_of( Types::string(), Types::integer() ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( 'foo' ) ); + $this->assertTrue( $validator->validate( 42 ) ); + + $result = $validator->validate( null ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be one of the specified types: null', $result->get_error_message() ); + } + + public function testListOfObjects(): void { + $schema = Types::list_of( + Types::object( [ + 'a_string' => Types::string(), + ] ) + ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( [ + [ 'a_string' => 'foo' ], + [ 'a_string' => 'bar' ], + ] ) ); + + $result = $validator->validate( [ + [ 'a_string' => 'foo' ], + [ 'a_string' => 42 ], + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be a list of the specified type: {"a_string":42}', $result->get_error_message() ); + $result = $validator->validate( [ + [ 'a_string' => 'foo' ], + 'foo', + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Value must be a list of the specified type: foo', $result->get_error_message() ); + } + + public function testNullableString(): void { + $nullable_validator = new Validator( Types::nullable( Types::string() ) ); + + $this->assertTrue( $nullable_validator->validate( null ) ); + $this->assertTrue( $nullable_validator->validate( 'foo' ) ); + } + + public function testObject(): void { + $schema = Types::object( [ + 'a_string' => Types::string(), + 'maybe_a_string' => Types::nullable( Types::string() ), + ] ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( [ 'a_string' => 'foo' ] ) ); + $this->assertTrue( $validator->validate( [ + 'a_string' => 'foo', + 'maybe_a_string' => 'foo', + ] ) ); + + $result = $validator->validate( [ 'a_string' => 42 ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Object must have valid property: a_string', $result->get_error_message() ); + + $result = $validator->validate( [] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Object must have valid property: a_string', $result->get_error_message() ); + } + + public function testNestedObject(): void { + $schema = Types::object( [ + 'nested1' => Types::object( [ + 'nested2' => Types::object( [ + 'a_string' => Types::string(), + 'list_of_objects' => Types::list_of( + Types::object( [ + 'a_boolean' => Types::boolean(), + ] ) + ), + ] ), + ] ), + ] ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( [ + 'nested1' => [ + 'nested2' => [ + 'a_string' => 'foo', + 'list_of_objects' => [ + [ 'a_boolean' => true ], + [ 'a_boolean' => false ], ], - 'array_field' => 'not_an_array', // This should be an array ], - 'boolean_field' => 'not_a_boolean', // This should be a boolean ], - ]; + ] ) ); - $schema = [ - 'type' => 'object', - 'properties' => [ - 'uuid' => [ 'type' => 'string' ], - 'service' => [ 'type' => 'string' ], - 'whatever' => [ - 'type' => 'object', - 'properties' => [ - 'level1' => [ - 'type' => 'object', - 'properties' => [ - 'level2' => [ - 'type' => 'object', - 'properties' => [ - 'key1' => [ 'type' => 'string' ], - 'key2' => [ 'type' => 'integer' ], - ], - ], - 'simple_array' => [ - 'type' => 'array', - 'items' => [ 'type' => 'string' ], - ], - ], - ], - 'boolean_field' => [ 'type' => 'boolean' ], + $result = $validator->validate( [ + 'nested1' => [ + 'nested2' => [ + 'a_string' => 'foo', + 'list_of_objects' => [ + [ 'a_boolean' => true ], + [ 'a_boolean' => 'foo' ], // Invalid ], ], ], - ]; + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Object must have valid property: nested1', $result->get_error_message() ); + } + + public function testRecord(): void { + $schema = Types::record( + Types::string(), + Types::integer() + ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( [ 'record_id' => 123 ] ) ); + $this->assertTrue( $validator->validate( [ + 'record_id' => 123, + 'foo' => 42, + ] ) ); + $this->assertTrue( $validator->validate( [] ) ); + + $result = $validator->validate( [ 'record_id' => '123' ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Record must have valid value: 123', $result->get_error_message() ); + } + + public function testRef(): void { + $schema = Types::object( [ + 'foo' => Types::create_ref( + 'my-ref', + Types::object( [ + 'a_string' => Types::string(), + ] ) + ), + 'bar' => Types::use_ref( 'my-ref' ), + ] ); $validator = new Validator( $schema ); - $result = $validator->validate( $invalid_nested_source ); + + $this->assertTrue( $validator->validate( [ + 'foo' => [ 'a_string' => 'foo' ], + 'bar' => [ 'a_string' => 'bar' ], + ] ) ); + + $result = $validator->validate( [ + 'foo' => [ 'a_string' => 'foo' ], + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Object must have valid property: bar', $result->get_error_message() ); + + $result = $validator->validate( [ + 'foo' => [ 'a_string' => 'foo' ], + 'bar' => [ 'a_string' => null ], // Invalid + ] ); + + $this->assertInstanceOf( WP_Error::class, $result ); + $this->assertSame( 'Object must have valid property: bar', $result->get_error_message() ); + } + + public function testStringMatching(): void { + $schema = Types::string_matching( '/^foo$/' ); + + $validator = new Validator( $schema ); + + $this->assertTrue( $validator->validate( 'foo' ) ); + + $result = $validator->validate( 'bar' ); $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertSame( 'invalid_type', $result->get_error_code() ); - $this->assertSame( 'Expected integer, got string.', $result->get_error_message() ); + $this->assertSame( 'Value must match the specified regex: bar', $result->get_error_message() ); } } diff --git a/tests/inc/WpdbStorage/DataSourceCrudTest.php b/tests/inc/WpdbStorage/DataSourceCrudTest.php index a1dbc91a..80b84460 100644 --- a/tests/inc/WpdbStorage/DataSourceCrudTest.php +++ b/tests/inc/WpdbStorage/DataSourceCrudTest.php @@ -3,7 +3,6 @@ namespace RemoteDataBlocks\Tests\WpdbStorage; use PHPUnit\Framework\TestCase; -use RemoteDataBlocks\Config\DataSource\HttpDataSource; use RemoteDataBlocks\WpdbStorage\DataSourceCrud; use WP_Error; @@ -11,31 +10,33 @@ class DataSourceCrudTest extends TestCase { protected function tearDown(): void { clear_mocked_options(); } - + public function test_register_new_data_source_with_valid_input() { $valid_source = [ 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'service_schema_version' => 1, - 'uuid' => wp_generate_uuid4(), - 'access_token' => 'valid_token', - 'base' => [ - 'id' => 'base_id', - 'name' => 'Base Name', + 'service_config' => [ + '__version' => 1, + 'access_token' => 'valid_token', + 'base' => [ + 'id' => 'base_id', + 'name' => 'Base Name', + ], + 'display_name' => 'Airtable Source', + 'tables' => [], ], - 'tables' => [], - 'display_name' => 'Crud Test', ]; - $result = DataSourceCrud::register_new_data_source( $valid_source ); + $result = DataSourceCrud::create_config( $valid_source ); - $this->assertInstanceOf( HttpDataSource::class, $result ); - $this->assertTrue( wp_is_uuid( $result->to_array()['uuid'] ) ); + $this->assertIsArray( $result ); + $this->assertSame( REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, $result['service'] ); + $this->assertTrue( wp_is_uuid( $result['uuid'] ) ); } public function test_register_new_data_source_with_invalid_input() { $invalid_source = [ 'service' => 'unsupported', - 'service_schema_version' => 1, + 'service_config' => [], 'uuid' => wp_generate_uuid4(), ]; @@ -46,134 +47,139 @@ public function test_register_new_data_source_with_invalid_input() { }, E_USER_WARNING); // phpcs:enable - $result = DataSourceCrud::register_new_data_source( $invalid_source ); + $result = DataSourceCrud::create_config( $invalid_source ); restore_error_handler(); $this->assertInstanceOf( WP_Error::class, $result ); - $this->assertSame( 'unsupported_data_source', $result->get_error_code() ); + $this->assertsame( 'unsupported_data_source', $result->get_error_code() ); } - public function test_get_data_sources() { - $source1 = DataSourceCrud::register_new_data_source( [ + public function test_get_configs() { + $source1 = DataSourceCrud::create_config( [ 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'service_schema_version' => 1, - 'uuid' => wp_generate_uuid4(), - 'access_token' => 'token1', - 'base' => [ - 'id' => 'base_id1', - 'name' => 'Base Name 1', + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token1', + 'display_name' => 'Airtable Source', + 'base' => [ + 'id' => 'base_id1', + 'name' => 'Base Name 1', + ], + 'tables' => [], ], - 'tables' => [], - 'display_name' => 'Base Name 1', + 'uuid' => wp_generate_uuid4(), ] ); - $source2 = DataSourceCrud::register_new_data_source( [ + $source2 = DataSourceCrud::create_config( [ 'service' => REMOTE_DATA_BLOCKS_SHOPIFY_SERVICE, - 'service_schema_version' => 1, + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token2', + 'display_name' => 'Shopify Source', + 'store_name' => 'mystore', + ], 'uuid' => wp_generate_uuid4(), - 'access_token' => 'token2', - 'store_name' => 'mystore', - ] ); - - set_mocked_option( DataSourceCrud::CONFIG_OPTION_NAME, [ - $source1->to_array(), - $source2->to_array(), ] ); - $all_sources = DataSourceCrud::get_data_sources(); + $all_sources = DataSourceCrud::get_configs(); $this->assertCount( 2, $all_sources ); - $airtable_sources = DataSourceCrud::get_data_sources( 'airtable' ); + $airtable_sources = DataSourceCrud::get_configs_by_service( 'airtable' ); $this->assertCount( 1, $airtable_sources ); - $this->assertSame( 'Base Name 1', $airtable_sources[0]['display_name'] ); + $this->assertSame( 'token1', $airtable_sources[0]['service_config']['access_token'] ); + $this->assertSame( $source1['uuid'], $airtable_sources[0]['uuid'] ); - $shopify_sources = DataSourceCrud::get_data_sources( 'shopify' ); + $shopify_sources = DataSourceCrud::get_configs_by_service( 'shopify' ); $this->assertCount( 1, $shopify_sources ); - $this->assertSame( 'mystore', $shopify_sources[0]['store_name'] ); + $this->assertSame( 'mystore', $shopify_sources[0]['service_config']['store_name'] ); + $this->assertSame( $source2['uuid'], $shopify_sources[0]['uuid'] ); } public function test_get_item_by_uuid_with_valid_uuid() { - $source = DataSourceCrud::register_new_data_source( [ + $source = DataSourceCrud::create_config( [ 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'service_schema_version' => 1, - 'uuid' => wp_generate_uuid4(), - 'access_token' => 'token1', - 'base' => [ - 'id' => 'base_id1', - 'name' => 'Base Name 1', + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token1', + 'base' => [ + 'id' => 'base_id1', + 'name' => 'Base Name 1', + ], + 'display_name' => 'Airtable Source', + 'tables' => [], ], - 'tables' => [], - 'display_name' => 'Crud Test', + 'uuid' => wp_generate_uuid4(), ] ); - $retrieved_source = DataSourceCrud::get_item_by_uuid( DataSourceCrud::get_data_sources(), $source->to_array()['uuid'] ); - $this->assertSame( 'token1', $retrieved_source['access_token'] ); - $this->assertSame( 'base_id1', $retrieved_source['base']['id'] ); - $this->assertSame( 'Base Name 1', $retrieved_source['base']['name'] ); - $this->assertSame( 'Crud Test', $retrieved_source['display_name'] ); + $retrieved_source = DataSourceCrud::get_config_by_uuid( $source['uuid'] ); $this->assertArrayHasKey( '__metadata', $retrieved_source ); $this->assertArrayHasKey( 'created_at', $retrieved_source['__metadata'] ); $this->assertArrayHasKey( 'updated_at', $retrieved_source['__metadata'] ); } public function test_get_item_by_uuid_with_invalid_uuid() { - $non_existent = DataSourceCrud::get_item_by_uuid( DataSourceCrud::get_data_sources(), 'non-existent-uuid' ); - $this->assertFalse( $non_existent ); + $non_existent = DataSourceCrud::get_config_by_uuid( 'non-existent-uuid' ); + $this->assertInstanceOf( WP_Error::class, $non_existent ); + $this->assertsame( 'data_source_not_found', $non_existent->get_error_code() ); } public function test_update_item_by_uuid_with_valid_uuid() { - $source = DataSourceCrud::register_new_data_source( [ + $source = DataSourceCrud::create_config( [ 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'service_schema_version' => 1, - 'uuid' => wp_generate_uuid4(), - 'newUUID' => '2ced7a88-5a61-4e66-840e-26baefdfdfd5', - 'access_token' => 'token1', - 'base' => [ - 'id' => 'base_id1', - 'name' => 'Base Name 1', + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token1', + 'base' => [ + 'id' => 'base_id1', + 'name' => 'Base Name 1', + ], + 'display_name' => 'Airtable Source', + 'tables' => [], ], - 'tables' => [], - 'display_name' => 'Crud Test', + 'uuid' => wp_generate_uuid4(), ] ); - $updated_source = DataSourceCrud::update_item_by_uuid( $source->to_array()['uuid'], [ + $updated_source = DataSourceCrud::update_config_by_uuid( $source['uuid'], [ 'access_token' => 'updated_token', - 'uuid' => '2ced7a88-5a61-4e66-840e-26baefdfdfd5', ] ); - $this->assertInstanceOf( HttpDataSource::class, $updated_source ); - $this->assertSame( 'updated_token', $updated_source->to_array()['access_token'] ); - $this->assertSame( '2ced7a88-5a61-4e66-840e-26baefdfdfd5', $updated_source->to_array()['uuid'] ); + $this->assertIsArray( $updated_source ); + $this->assertSame( 'updated_token', $updated_source['service_config']['access_token'] ); } public function test_update_item_by_uuid_with_invalid_uuid() { - $non_existent = DataSourceCrud::update_item_by_uuid( 'non-existent-uuid', [ 'token' => 'new_token' ] ); + $non_existent = DataSourceCrud::update_config_by_uuid( 'non-existent-uuid', [ 'token' => 'new_token' ] ); $this->assertInstanceOf( WP_Error::class, $non_existent ); + $this->assertSame( 'data_source_not_found', $non_existent->get_error_code() ); } public function test_delete_item_by_uuid() { - $source = DataSourceCrud::register_new_data_source( [ + $source = DataSourceCrud::create_config( [ 'service' => REMOTE_DATA_BLOCKS_AIRTABLE_SERVICE, - 'service_schema_version' => 1, - 'uuid' => wp_generate_uuid4(), - 'access_token' => 'token1', - 'base' => [ - 'id' => 'base_id1', - 'name' => 'Base Name 1', + 'service_config' => [ + '__version' => 1, + 'access_token' => 'token1', + 'base' => [ + 'id' => 'base_id1', + 'name' => 'Base Name 1', + ], + 'display_name' => 'Airtable Source', + 'tables' => [], ], - 'tables' => [], - 'display_name' => 'Crud Test', + 'uuid' => wp_generate_uuid4(), ] ); - $result = DataSourceCrud::delete_item_by_uuid( $source->to_array()['uuid'] ); + $result = DataSourceCrud::delete_config_by_uuid( $source['uuid'] ); $this->assertTrue( $result ); - $deleted_source = DataSourceCrud::get_item_by_uuid( DataSourceCrud::get_data_sources(), $source->to_array()['uuid'] ); - $this->assertFalse( $deleted_source ); + $deleted_source = DataSourceCrud::get_config_by_uuid( $source['uuid'] ); + $this->assertInstanceOf( WP_Error::class, $deleted_source ); + $this->assertSame( 'data_source_not_found', $deleted_source->get_error_code() ); } public function test_get_by_uuid_with_non_existent_uuid() { - $non_existent = DataSourceCrud::get_by_uuid( '64af9297-867e-4e39-b51d-7c97beeebec6' ); - $this->assertFalse( $non_existent ); + $non_existent = DataSourceCrud::get_config_by_uuid( '64af9297-867e-4e39-b51d-7c97beeebec6' ); + $this->assertInstanceOf( WP_Error::class, $non_existent ); + $this->assertSame( 'data_source_not_found', $non_existent->get_error_code() ); } } diff --git a/tests/inc/stubs.php b/tests/inc/stubs.php index 64ae441e..8d32ceea 100644 --- a/tests/inc/stubs.php +++ b/tests/inc/stubs.php @@ -35,10 +35,18 @@ function get_bloginfo( $_property ): void { // Do nothing } +function plugins_url( string $path ): string { + return sprintf( 'https://example.com/%s/', $path ); +} + function sanitize_title( string $title ): string { return str_replace( ' ', '-', strtolower( $title ) ); } +function sanitize_title_with_dashes( string $title ): string { + return preg_replace( '/[^a-z0-9-]/', '-', sanitize_title( $title ) ); +} + function sanitize_text_field( string $text ): string { // phpcs:ignore WordPressVIPMinimum.Functions.StripTags.StripTagsOneParameter $text = strip_tags( $text ); diff --git a/types/localized-block-data.d.ts b/types/localized-block-data.d.ts index ceb6141e..8f8c686f 100644 --- a/types/localized-block-data.d.ts +++ b/types/localized-block-data.d.ts @@ -1,12 +1,6 @@ type RemoteDataBinding = Pick< RemoteDataResultFields, 'name' | 'type' >; type AvailableBindings = Record< string, RemoteDataBinding >; -interface InputVariableOverrides { - name: string; - overrides: QueryInputOverride[]; - type: string; -} - interface InputVariable { name: string; required: boolean; @@ -19,7 +13,7 @@ interface BlockConfig { dataSourceType: string; loop: boolean; name: string; - overrides: Record< string, InputVariableOverrides >; + overrides: Record< string, QueryInputOverride[] >; patterns: { default: string; inner_blocks?: string; diff --git a/types/remote-data.d.ts b/types/remote-data.d.ts index 313ceab9..f4e23d13 100644 --- a/types/remote-data.d.ts +++ b/types/remote-data.d.ts @@ -10,8 +10,8 @@ interface RemoteDataResultFields { interface QueryInputOverride { display: string; - target: string; - type: 'query_var' | 'url'; + source: string; + sourceType: 'query_var'; } interface RemoteData { @@ -71,7 +71,6 @@ interface RemoteDataApiRequest { } interface RemoteDataApiResult { - output: Record< string, string >; result: Record< string, RemoteDataResultFields >; }