-
Notifications
You must be signed in to change notification settings - Fork 3.3k
Connectors: Backport Gutenberg PR #75833 PHP integration #11056
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: trunk
Are you sure you want to change the base?
Changes from all commits
a931561
409f90f
e26814d
81a4100
31ab71c
734a6b3
a9539cf
9920293
0625624
4c380a9
21201c5
0b6f6c1
b9eccfe
52fbdca
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,280 @@ | ||
| <?php | ||
| /** | ||
| * Connectors API. | ||
| * | ||
| * @package WordPress | ||
| * @subpackage Connectors | ||
| * @since 7.0.0 | ||
| */ | ||
|
|
||
| use WordPress\AiClient\AiClient; | ||
| use WordPress\AiClient\Providers\Http\DTO\ApiKeyRequestAuthentication; | ||
|
|
||
| /** | ||
| * Registers the Connectors menu item under Settings. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| */ | ||
| function _wp_connectors_add_settings_menu_item(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) || ! function_exists( 'wp_connectors_wp_admin_render_page' ) ) { | ||
| return; | ||
| } | ||
|
|
||
| add_submenu_page( | ||
| 'options-general.php', | ||
| __( 'Connectors' ), | ||
| __( 'Connectors' ), | ||
| 'manage_options', | ||
| 'connectors-wp-admin', | ||
| 'wp_connectors_wp_admin_render_page', | ||
| 1 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why the menu position is set to
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| ); | ||
| } | ||
| add_action( 'admin_menu', '_wp_connectors_add_settings_menu_item' ); | ||
|
|
||
| /** | ||
| * Masks an API key, showing only the last 4 characters. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @param string $key The API key to mask. | ||
| * @return string The masked key, e.g. "************fj39". | ||
| */ | ||
| function _wp_connectors_mask_api_key( string $key ): string { | ||
| if ( strlen( $key ) <= 4 ) { | ||
| return $key; | ||
| } | ||
|
|
||
| return str_repeat( "\u{2022}", min( strlen( $key ) - 4, 16 ) ) . substr( $key, -4 ); | ||
| } | ||
|
|
||
| /** | ||
| * Checks whether an API key is valid for a given provider. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @param string $key The API key to check. | ||
| * @param string $provider_id The WP AI client provider ID. | ||
| * @return bool|null True if valid, false if invalid, null if unable to determine. | ||
| */ | ||
| function _wp_connectors_is_api_key_valid( string $key, string $provider_id ): ?bool { | ||
| try { | ||
| $registry = AiClient::defaultRegistry(); | ||
|
|
||
| if ( ! $registry->hasProvider( $provider_id ) ) { | ||
| _doing_it_wrong( | ||
| __FUNCTION__, | ||
| sprintf( | ||
| /* translators: %s: AI provider ID. */ | ||
| __( 'The provider "%s" is not registered in the AI client registry.' ), | ||
| $provider_id | ||
| ), | ||
| '7.0.0' | ||
| ); | ||
| return null; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $provider_id, | ||
| new ApiKeyRequestAuthentication( $key ) | ||
| ); | ||
|
|
||
| return $registry->isProviderConfigured( $provider_id ); | ||
| } catch ( Exception $e ) { | ||
| wp_trigger_error( __FUNCTION__, $e->getMessage() ); | ||
| return null; | ||
|
Comment on lines
77
to
88
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think for both of the could use
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done with 31ab71c. I still need to look into it, because we most likely will need to pass here some generic translatable message instead of the code-generated error message. |
||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Retrieves the real (unmasked) value of a connector API key. | ||
| * | ||
| * Temporarily removes the masking filter, reads the option, then re-adds it. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @param string $option_name The option name for the API key. | ||
| * @param callable $mask_callback The mask filter function. | ||
| * @return string The real API key value. | ||
| */ | ||
| function _wp_connectors_get_real_api_key( string $option_name, callable $mask_callback ): string { | ||
| remove_filter( "option_{$option_name}", $mask_callback ); | ||
| $value = get_option( $option_name, '' ); | ||
| add_filter( "option_{$option_name}", $mask_callback ); | ||
| return (string) $value; | ||
| } | ||
|
|
||
| /** | ||
| * Gets the registered connector provider settings. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @return array<string, array{provider: string, label: string, description: string, mask: callable, sanitize: callable}> Provider settings keyed by setting name. | ||
| */ | ||
| function _wp_connectors_get_provider_settings(): array { | ||
| $providers = array( | ||
| 'google' => array( | ||
| 'name' => 'Google', | ||
| ), | ||
| 'openai' => array( | ||
| 'name' => 'OpenAI', | ||
| ), | ||
| 'anthropic' => array( | ||
| 'name' => 'Anthropic', | ||
| ), | ||
| ); | ||
|
|
||
| $provider_settings = array(); | ||
| foreach ( $providers as $provider => $data ) { | ||
| $setting_name = "connectors_ai_{$provider}_api_key"; | ||
|
|
||
| $provider_settings[ $setting_name ] = array( | ||
| 'provider' => $provider, | ||
| 'label' => sprintf( | ||
| /* translators: %s: AI provider name. */ | ||
| __( '%s API Key' ), | ||
| $data['name'] | ||
| ), | ||
| 'description' => sprintf( | ||
| /* translators: %s: AI provider name. */ | ||
| __( 'API key for the %s AI provider.' ), | ||
| $data['name'] | ||
| ), | ||
| 'mask' => '_wp_connectors_mask_api_key', | ||
| 'sanitize' => static function ( string $value ) use ( $provider ): string { | ||
| $value = sanitize_text_field( $value ); | ||
| if ( '' === $value ) { | ||
| return $value; | ||
| } | ||
|
|
||
| $valid = _wp_connectors_is_api_key_valid( $value, $provider ); | ||
| return true === $valid ? $value : ''; | ||
| }, | ||
| ); | ||
| } | ||
| return $provider_settings; | ||
| } | ||
|
|
||
| /** | ||
| * Validates connector API keys in the REST response when explicitly requested. | ||
| * | ||
| * Runs on `rest_post_dispatch` for `/wp/v2/settings` requests that include connector | ||
| * fields via `_fields`. For each requested connector field, it validates the unmasked | ||
| * key against the provider and replaces the response value with `invalid_key` if | ||
| * validation fails. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| * | ||
| * @param WP_REST_Response $response The response object. | ||
| * @param WP_REST_Server $server The server instance. | ||
| * @param WP_REST_Request $request The request object. | ||
| * @return WP_REST_Response The potentially modified response. | ||
| */ | ||
| function _wp_connectors_validate_keys_in_rest( WP_REST_Response $response, WP_REST_Server $server, WP_REST_Request $request ): WP_REST_Response { | ||
| if ( '/wp/v2/settings' !== $request->get_route() ) { | ||
| return $response; | ||
| } | ||
|
|
||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return $response; | ||
| } | ||
|
|
||
| $fields = $request->get_param( '_fields' ); | ||
| if ( ! $fields ) { | ||
| return $response; | ||
| } | ||
|
|
||
| if ( is_array( $fields ) ) { | ||
| $requested = $fields; | ||
| } else { | ||
| $requested = array_map( 'trim', explode( ',', $fields ) ); | ||
| } | ||
|
|
||
| $data = $response->get_data(); | ||
| if ( ! is_array( $data ) ) { | ||
| return $response; | ||
| } | ||
|
|
||
| foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { | ||
| if ( ! in_array( $setting_name, $requested, true ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $real_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); | ||
| if ( '' === $real_key ) { | ||
| continue; | ||
| } | ||
|
|
||
| if ( true !== _wp_connectors_is_api_key_valid( $real_key, $config['provider'] ) ) { | ||
| $data[ $setting_name ] = 'invalid_key'; | ||
| } | ||
| } | ||
|
|
||
| $response->set_data( $data ); | ||
| return $response; | ||
| } | ||
| add_filter( 'rest_post_dispatch', '_wp_connectors_validate_keys_in_rest', 10, 3 ); | ||
|
|
||
| /** | ||
| * Registers default connector settings and mask/sanitize filters. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| */ | ||
gziolo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| function _wp_register_default_connector_settings(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @jorgefilipecosta, do we need this check in WP core? I bet we don't.
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well we don't excatly "need" but I would prefer to be defensive here, in the same way code normally checks for the inclusion of build generated functions function_exists( 'wp_connectors_wp_admin_render_page' ), AI client also seems like an external lib we had conversations in the past about some constant that makes AI client not even load etc, so I think being defensive here may be good.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WP AI Client development moves to the WP core. PHP AI Client is only treated as an external library. |
||
| return; | ||
| } | ||
|
|
||
| foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { | ||
| register_setting( | ||
| 'connectors', | ||
| $setting_name, | ||
| array( | ||
| 'type' => 'string', | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This works for now, but is problematic and needs to be improved before stable release: Not every credential is a single string. A connector may require two fields (e.g. ID and secret, username and password, ...). We need to abstract this in a way that it's not enforcing a single option per provider usage - or at the very least, if we use a single option, it also needs to allow for more complex data, e.g. an object / associative array with multiple fields in it. Once we have the connector registry, I think a good idea might be to have an (Again, for some predefined authentication methods like "api_key" we could auto-populate this.) cc @gziolo
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I refactored |
||
| 'label' => $config['label'], | ||
| 'description' => $config['description'], | ||
| 'default' => '', | ||
| 'show_in_rest' => true, | ||
| 'sanitize_callback' => $config['sanitize'], | ||
| ) | ||
| ); | ||
| add_filter( "option_{$setting_name}", $config['mask'] ); | ||
| } | ||
| } | ||
| add_action( 'init', '_wp_register_default_connector_settings' ); | ||
|
|
||
| /** | ||
| * Passes stored connector API keys to the WP AI client. | ||
| * | ||
| * @since 7.0.0 | ||
| * @access private | ||
| */ | ||
gziolo marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| function _wp_connectors_pass_default_keys_to_ai_client(): void { | ||
| if ( ! class_exists( '\WordPress\AiClient\AiClient' ) ) { | ||
| return; | ||
| } | ||
| try { | ||
| $registry = AiClient::defaultRegistry(); | ||
| foreach ( _wp_connectors_get_provider_settings() as $setting_name => $config ) { | ||
| $api_key = _wp_connectors_get_real_api_key( $setting_name, $config['mask'] ); | ||
| if ( '' === $api_key || ! $registry->hasProvider( $config['provider'] ) ) { | ||
| continue; | ||
| } | ||
|
|
||
| $registry->setProviderRequestAuthentication( | ||
| $config['provider'], | ||
| new ApiKeyRequestAuthentication( $api_key ) | ||
| ); | ||
| } | ||
| } catch ( Exception $e ) { | ||
| wp_trigger_error( __FUNCTION__, $e->getMessage() ); | ||
| } | ||
| } | ||
| add_action( 'init', '_wp_connectors_pass_default_keys_to_ai_client' ); | ||
Uh oh!
There was an error while loading. Please reload this page.