diff --git a/.wp-env.override.example.json b/.wp-env.override.example.json index 8d30d86a..9249db57 100644 --- a/.wp-env.override.example.json +++ b/.wp-env.override.example.json @@ -2,6 +2,7 @@ "config": { "REMOTE_DATA_BLOCKS_EXAMPLE_AIRTABLE_ELDEN_RING_ACCESS_TOKEN": "", "REMOTE_DATA_BLOCKS_EXAMPLE_AIRTABLE_EVENTS_ACCESS_TOKEN": "", - "REMOTE_DATA_BLOCKS_EXAMPLE_SHOPIFY_ACCESS_TOKEN": "" + "REMOTE_DATA_BLOCKS_EXAMPLE_SHOPIFY_ACCESS_TOKEN": "", + "REMOTE_DATA_BLOCKS_EXAMPLE_GOOGLE_SHEETS_WESTEROS_HOUSES_ACCESS_TOKEN": "" } } 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 new file mode 100644 index 00000000..301667c6 --- /dev/null +++ b/example/google-sheets/westeros-houses/inc/queries/class-get-westeros-houses-query.php @@ -0,0 +1,81 @@ + [ + 'name' => 'Row ID', + 'overrides' => [ + [ + 'target' => 'utm_content', + 'type' => 'query_var', + ], + ], + 'type' => 'id', + ], + ]; + + public array $output_variables = [ + '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 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 new file mode 100644 index 00000000..77d36a56 --- /dev/null +++ b/example/google-sheets/westeros-houses/inc/queries/class-list-westeros-houses-query.php @@ -0,0 +1,75 @@ + '$.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 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/inc/queries/class-westeros-houses-datasource.php b/example/google-sheets/westeros-houses/inc/queries/class-westeros-houses-datasource.php new file mode 100644 index 00000000..3d06412c --- /dev/null +++ b/example/google-sheets/westeros-houses/inc/queries/class-westeros-houses-datasource.php @@ -0,0 +1,39 @@ +credentials = json_decode( base64_decode( $credentials ), true ); + } + + public function get_endpoint(): string { + return 'https://sheets.googleapis.com/v4/spreadsheets/' . + '1EHdQg53Doz0B-ImrGz_hTleYeSvkVIk_NSJCOM1FQk0/values/Houses'; + } + + public function get_display_name(): string { + return 'Westeros Houses'; + } + + public function get_request_headers(): array { + $access_token = GoogleAuth::generate_token_from_service_account_key( + $this->credentials, + GoogleAuth::GOOGLE_SHEETS_SCOPES + ); + + return [ + 'Authorization' => sprintf( 'Bearer %s', $access_token ), + 'Content-Type' => 'application/json', + ]; + } +} diff --git a/example/google-sheets/westeros-houses/register.php b/example/google-sheets/westeros-houses/register.php new file mode 100644 index 00000000..f18384bb --- /dev/null +++ b/example/google-sheets/westeros-houses/register.php @@ -0,0 +1,39 @@ +warning( + sprintf( + '%s is not defined, cannot register %s block', + 'EXAMPLE_GOOGLE_SHEETS_WESTEROS_HOUSES_ACCESS_TOKEN', + $block_name + ) + ); + return; + } + + $westeros_houses_datasource = new WesterosHousesDatasource( $access_token ); + $list_westeros_houses_query = new ListWesterosHousesQuery( $westeros_houses_datasource ); + $get_westeros_houses_query = new GetWesterosHousesQuery( $westeros_houses_datasource ); + + ConfigurationLoader::register_block( $block_name, $get_westeros_houses_query ); + ConfigurationLoader::register_list_query( $block_name, $list_westeros_houses_query ); + ConfigurationLoader::register_loop_block( 'Westeros Houses List', $list_westeros_houses_query ); + ConfigurationLoader::register_page( $block_name, 'westeros-houses' ); +} + +add_action( 'register_remote_data_blocks', __NAMESPACE__ . '\\register_westeros_houses_block' ); diff --git a/example/remote-data-blocks-example-code.php b/example/remote-data-blocks-example-code.php index b7f47815..fad1ef90 100644 --- a/example/remote-data-blocks-example-code.php +++ b/example/remote-data-blocks-example-code.php @@ -40,6 +40,7 @@ function load_only_if_parent_plugin_is_active() { require_once __DIR__ . '/rest-api/art-institute/register.php'; require_once __DIR__ . '/rest-api/zip-code/register.php'; require_once __DIR__ . '/shopify/register.php'; + require_once __DIR__ . '/google-sheets/westeros-houses/register.php'; } add_action( 'plugins_loaded', __NAMESPACE__ . '\\load_only_if_parent_plugin_is_active', 10, 0 ); @@ -54,6 +55,7 @@ function get_access_token( string $example_name ): string { 'airtable_elden_ring', 'airtable_events', 'shopify', + 'google_sheets_westeros_houses', ]; if ( ! in_array( $example_name, $supported_tokens, true ) ) { diff --git a/inc/config/auth/google-auth/google-auth.php b/inc/integrations/google/auth/google-auth/google-auth.php similarity index 64% rename from inc/config/auth/google-auth/google-auth.php rename to inc/integrations/google/auth/google-auth/google-auth.php index c345473b..25728efc 100644 --- a/inc/config/auth/google-auth/google-auth.php +++ b/inc/integrations/google/auth/google-auth/google-auth.php @@ -1,10 +1,16 @@ client_email; + if ( ! $no_cache ) { + $cached_token = wp_cache_get( $cache_key, 'oauth-tokens' ); + if ( false !== $cached_token ) { + return $cached_token; + } + } + $jwt = self::generate_jwt( $service_account_key, $scope ); $token_uri = $service_account_key->token_uri; - return self::get_token_using_jwt( $jwt, $token_uri ); + $token = self::get_token_using_jwt( $jwt, $token_uri ); + + if ( ! is_wp_error( $token ) ) { + wp_cache_set( + $cache_key, + $token, + 'oauth-tokens', + 3000, // 50 minutes + ); + } + + return $token; } + /** + * Get an access token using a JWT. + * + * @param string $jwt The JWT. + * @param string $token_uri The token URI. + * @return WP_Error|string The access token or an error. + */ private static function get_token_using_jwt( string $jwt, string $token_uri ): WP_Error|string { $response = wp_remote_post( $token_uri, @@ -84,6 +127,13 @@ private static function get_token_using_jwt( string $jwt, string $token_uri ): W return $response_data['access_token']; } + /** + * Generate a JWT. + * + * @param GoogleServiceAccountKey $service_account_key The service account key. + * @param string $scope The scope. + * @return string The JWT. + */ private static function generate_jwt( GoogleServiceAccountKey $service_account_key, string $scope @@ -108,6 +158,14 @@ private static function generate_jwt( return $base64_url_header . '.' . $base64_url_payload . '.' . $base64_url_signature; } + /** + * Generate a JWT signature. + * + * @param string $base64_url_header The base64 URL encoded header. + * @param string $base64_url_payload The base64 URL encoded payload. + * @param string $private_key The private key. + * @return string The JWT signature. + */ private static function generate_jwt_signature( string $base64_url_header, string $base64_url_payload, @@ -119,6 +177,11 @@ private static function generate_jwt_signature( return $signature; } + /** + * Generate a JWT header. + * + * @return array The JWT header. + */ private static function generate_jwt_header(): array { $header = [ 'alg' => 'RS256', @@ -128,6 +191,14 @@ private static function generate_jwt_header(): array { return $header; } + /** + * Generate a JWT payload. + * + * @param string $client_email The client email. + * @param string $token_uri The token URI. + * @param string $scope The scope. + * @return array The JWT payload. + */ private static function generate_jwt_payload( string $client_email, string $token_uri, diff --git a/inc/config/auth/google-service-account-key/google-service-account-key.php b/inc/integrations/google/auth/google-service-account-key/google-service-account-key.php similarity index 98% rename from inc/config/auth/google-service-account-key/google-service-account-key.php rename to inc/integrations/google/auth/google-service-account-key/google-service-account-key.php index d94f59fe..b79c8e61 100644 --- a/inc/config/auth/google-service-account-key/google-service-account-key.php +++ b/inc/integrations/google/auth/google-service-account-key/google-service-account-key.php @@ -1,6 +1,6 @@ namespace = REMOTE_DATA_BLOCKS__REST_NAMESPACE; @@ -16,6 +22,11 @@ public function __construct() { } public function register_routes() { + /** + * API to get Google Access Token using a Credentials/Keys JSON file. + * Currently only supports service account keys. + * Could be extended to support OAuth2.0 Client Keys in the future. + */ register_rest_route( $this->namespace, '/' . $this->rest_base . '/google/token', @@ -42,7 +53,7 @@ public function get_google_auth_token( WP_REST_Request $request ) { } if ( 'service_account' === $type ) { - $token = GoogleAuth::generate_token_from_service_account_key( $credentials, $scopes ); + $token = GoogleAuth::generate_token_from_service_account_key( $credentials, $scopes, true ); if ( is_wp_error( $token ) ) { return rest_ensure_response( $token ); } @@ -56,6 +67,10 @@ public function get_google_auth_token( WP_REST_Request $request ) { ); } + /** + * These all require manage_options for now, but we can adjust as needed. + * Taken from /inc/rest/datasource-controller/datasource-controller.php + */ public function get_google_auth_token_permissions_check( $request ) { return current_user_can( 'manage_options' ); } diff --git a/inc/rest/datasource-crud/datasource-crud.php b/inc/rest/datasource-crud/datasource-crud.php index 14b9202b..ec927ad0 100644 --- a/inc/rest/datasource-crud/datasource-crud.php +++ b/inc/rest/datasource-crud/datasource-crud.php @@ -2,7 +2,7 @@ namespace RemoteDataBlocks\REST; -use RemoteDataBlocks\Config\Auth\GoogleServiceAccountKey; +use RemoteDataBlocks\Integrations\Google\Auth\GoogleServiceAccountKey; use WP_Error; class DatasourceCRUD { diff --git a/tests/inc/rest/DatasourceCrudTest.php b/tests/inc/rest/DatasourceCrudTest.php index 2f970d44..2724095c 100644 --- a/tests/inc/rest/DatasourceCrudTest.php +++ b/tests/inc/rest/DatasourceCrudTest.php @@ -2,7 +2,7 @@ use PHPUnit\Framework\TestCase; use RemoteDataBlocks\REST\DatasourceCRUD; -use RemoteDataBlocks\Config\Auth\GoogleServiceAccountKey; +use RemoteDataBlocks\Integrations\Google\Auth\GoogleServiceAccountKey; class DatasourceCrudTest extends TestCase { protected function tearDown(): void {