From 0d1ae47cde4d0f9bbff414fd43a3ef09a52fce5e Mon Sep 17 00:00:00 2001 From: jorgefilipecosta Date: Mon, 30 Mar 2026 14:26:47 +0000 Subject: [PATCH] Connectors: Fix and generalize the API for custom connector types. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validate `setting_name`, `constant_name`, and `env_var_name` in connector registration — reject invalid values with `_doing_it_wrong()` instead of silently falling back. Change the auto-generated `setting_name` pattern from `connectors_ai_{$id}_api_key` to `connectors_{$type}_{$id}_api_key` so it works for any connector type. Built-in AI providers infer their names using the existing `connectors_ai_{$id}_api_key` convention, preserving backward compatibility. Add `constant_name` and `env_var_name` as optional authentication fields, allowing connectors to declare explicit PHP constant and environment variable names for API key lookup. AI providers auto-generate these using the `{CONSTANT_CASE_ID}_API_KEY` convention. Refactor `_wp_connectors_get_api_key_source()` to accept explicit `env_var_name` and `constant_name` parameters instead of deriving them from the provider ID. Environment variable and constant checks are skipped when not provided. Generalize REST dispatch, settings registration, and script module data to work with all connector types, not just `ai_provider`. Settings registration skips already-registered settings. Non-AI connectors determine `isConnected` based on key source. Replace `isInstalled` with `pluginFile` in script module data output to fix plugin entity ID resolution on the frontend. Update PHPDoc to reflect current behavior — widen `type` from literal `'ai_provider'` to `non-empty-string`, document new authentication fields, and use Anthropic examples throughout. Props gziolo, jorgefilipecosta. Fixes #64957. Built from https://develop.svn.wordpress.org/trunk@62180 git-svn-id: http://core.svn.wordpress.org/trunk@61462 1a063a9b-81f0-0310-95a4-ce76da25c4cd --- wp-includes/class-wp-connector-registry.php | 65 ++++++++-- wp-includes/connectors.php | 134 +++++++++++++------- wp-includes/version.php | 2 +- 3 files changed, 143 insertions(+), 58 deletions(-) diff --git a/wp-includes/class-wp-connector-registry.php b/wp-includes/class-wp-connector-registry.php index a4bd8fb48f..18a5f80c94 100644 --- a/wp-includes/class-wp-connector-registry.php +++ b/wp-includes/class-wp-connector-registry.php @@ -31,11 +31,13 @@ * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ * slug: non-empty-string @@ -66,12 +68,12 @@ final class WP_Connector_Registry { * Registers a new connector. * * Validates the provided arguments and stores the connector in the registry. - * For connectors with `api_key` authentication, a `setting_name` is automatically - * generated using the pattern `connectors_ai_{$id}_api_key`, with hyphens in the ID - * normalized to underscores (e.g., connector ID `openai` produces - * `connectors_ai_openai_api_key`, and `azure-openai` produces - * `connectors_ai_azure_openai_api_key`). This setting name is used for the Settings - * API registration and REST API exposure. + * For connectors with `api_key` authentication, a `setting_name` can be provided + * explicitly. If omitted, one is automatically generated using the pattern + * `connectors_{$type}_{$id}_api_key`, with hyphens in the type and ID normalized + * to underscores (e.g., connector type `spam_filtering` with ID `akismet` produces + * `connectors_spam_filtering_akismet_api_key`). This setting name is used for the + * Settings API registration and REST API exposure. * * Registering a connector with an ID that is already registered will trigger a * `_doing_it_wrong()` notice and return `null`. To override an existing connector, @@ -89,12 +91,20 @@ final class WP_Connector_Registry { * @type string $name Required. The connector's display name. * @type string $description Optional. The connector's description. Default empty string. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type Required. The connector type. Currently, only 'ai_provider' is supported. + * @type string $type Required. The connector type, e.g. 'ai_provider'. * @type array $authentication { * Required. Authentication configuration. * * @type string $method Required. The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. + * @type string $setting_name Optional. The setting name for the API key. + * When omitted, auto-generated as + * `connectors_{$type}_{$id}_api_key`. + * Must be a non-empty string when provided. + * @type string $constant_name Optional. PHP constant name for the API key + * (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided. + * @type string $env_var_name Optional. Environment variable name for the API key + * (e.g. 'ANTHROPIC_API_KEY'). Only checked when provided. * } * @type array $plugin { * Optional. Plugin data for install/activate UI. @@ -192,10 +202,43 @@ final class WP_Connector_Registry { if ( ! empty( $args['authentication']['credentials_url'] ) && is_string( $args['authentication']['credentials_url'] ) ) { $connector['authentication']['credentials_url'] = $args['authentication']['credentials_url']; } - if ( ! empty( $args['authentication']['setting_name'] ) && is_string( $args['authentication']['setting_name'] ) ) { + if ( isset( $args['authentication']['setting_name'] ) ) { + if ( ! is_string( $args['authentication']['setting_name'] ) || '' === $args['authentication']['setting_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication setting_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } $connector['authentication']['setting_name'] = $args['authentication']['setting_name']; } else { - $connector['authentication']['setting_name'] = 'connectors_ai_' . str_replace( '-', '_', $id ) . '_api_key'; + $connector['authentication']['setting_name'] = str_replace( '-', '_', "connectors_{$connector['type']}_{$id}_api_key" ); + } + if ( isset( $args['authentication']['constant_name'] ) ) { + if ( ! is_string( $args['authentication']['constant_name'] ) || '' === $args['authentication']['constant_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication constant_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + $connector['authentication']['constant_name'] = $args['authentication']['constant_name']; + } + if ( isset( $args['authentication']['env_var_name'] ) ) { + if ( ! is_string( $args['authentication']['env_var_name'] ) || '' === $args['authentication']['env_var_name'] ) { + _doing_it_wrong( + __METHOD__, + /* translators: %s: Connector ID. */ + sprintf( __( 'Connector "%s" authentication env_var_name must be a non-empty string.' ), esc_html( $id ) ), + '7.0.0' + ); + return null; + } + $connector['authentication']['env_var_name'] = $args['authentication']['env_var_name']; } } diff --git a/wp-includes/connectors.php b/wp-includes/connectors.php index bdc585723a..06683ccaaa 100644 --- a/wp-includes/connectors.php +++ b/wp-includes/connectors.php @@ -43,14 +43,17 @@ function wp_is_connector_registered( string $id ): bool { * @type string $name The connector's display name. * @type string $description The connector's description. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type string $type The connector type, e.g. 'ai_provider'. * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. + * credentials_url, setting_name, and optionally constant_name and + * env_var_name. When 'none', only method is present. * * @type string $method The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. * @type string $setting_name Optional. The setting name for the API key. + * @type string $constant_name Optional. PHP constant name for the API key. + * @type string $env_var_name Optional. Environment variable name for the API key. * } * @type array $plugin { * Optional. Plugin data for install/activate UI. @@ -62,11 +65,13 @@ function wp_is_connector_registered( string $id ): bool { * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ * slug: non-empty-string @@ -98,14 +103,17 @@ function wp_get_connector( string $id ): ?array { * @type string $name The connector's display name. * @type string $description The connector's description. * @type string $logo_url Optional. URL to the connector's logo image. - * @type string $type The connector type. Currently, only 'ai_provider' is supported. + * @type string $type The connector type, e.g. 'ai_provider'. * @type array $authentication { * Authentication configuration. When method is 'api_key', includes - * credentials_url and setting_name. When 'none', only method is present. + * credentials_url, setting_name, and optionally constant_name and + * env_var_name. When 'none', only method is present. * * @type string $method The authentication method: 'api_key' or 'none'. * @type string $credentials_url Optional. URL where users can obtain API credentials. * @type string $setting_name Optional. The setting name for the API key. + * @type string $constant_name Optional. PHP constant name for the API key. + * @type string $env_var_name Optional. Environment variable name for the API key. * } * @type array $plugin { * Optional. Plugin data for install/activate UI. @@ -118,11 +126,13 @@ function wp_get_connector( string $id ): ?array { * name: non-empty-string, * description: non-empty-string, * logo_url?: non-empty-string, - * type: 'ai_provider', + * type: non-empty-string, * authentication: array{ * method: 'api_key'|'none', * credentials_url?: non-empty-string, - * setting_name?: non-empty-string + * setting_name?: non-empty-string, + * constant_name?: non-empty-string, + * env_var_name?: non-empty-string * }, * plugin?: array{ * slug: non-empty-string @@ -216,10 +226,10 @@ function _wp_connectors_init(): void { * Example — overriding metadata on an auto-discovered connector: * * add_action( 'wp_connectors_init', function ( WP_Connector_Registry $registry ) { - * if ( $registry->is_registered( 'openai' ) ) { - * $connector = $registry->unregister( 'openai' ); - * $connector['description'] = __( 'Custom description for OpenAI.', 'my-plugin' ); - * $registry->register( 'openai', $connector ); + * if ( $registry->is_registered( 'anthropic' ) ) { + * $connector = $registry->unregister( 'anthropic' ); + * $connector['description'] = __( 'Custom description for Anthropic.', 'my-plugin' ); + * $registry->register( 'anthropic', $connector ); * } * } ); * @@ -335,6 +345,26 @@ function _wp_connectors_register_default_ai_providers( WP_Connector_Registry $re // Register all default connectors directly on the registry. foreach ( $defaults as $id => $args ) { + if ( 'api_key' === $args['authentication']['method'] ) { + $sanitized_id = str_replace( '-', '_', $id ); + + if ( ! isset( $args['authentication']['setting_name'] ) ) { + $args['authentication']['setting_name'] = "connectors_ai_{$sanitized_id}_api_key"; + } + + // All AI providers use the {CONSTANT_CASE_ID}_API_KEY naming convention. + if ( ! isset( $args['authentication']['constant_name'] ) || ! isset( $args['authentication']['env_var_name'] ) ) { + $constant_case_key = strtoupper( preg_replace( '/([a-z])([A-Z])/', '$1_$2', $sanitized_id ) ) . '_API_KEY'; + + if ( ! isset( $args['authentication']['constant_name'] ) ) { + $args['authentication']['constant_name'] = $constant_case_key; + } + + if ( ! isset( $args['authentication']['env_var_name'] ) ) { + $args['authentication']['env_var_name'] = $constant_case_key; + } + } + } $registry->register( $id, $args ); } } @@ -357,35 +387,32 @@ function _wp_connectors_mask_api_key( string $key ): string { } /** - * Determines the source of an API key for a given provider. + * Determines the source of an API key for a given connector. * * Checks in order: environment variable, PHP constant, database. - * Uses the same naming convention as the WP AI Client ProviderRegistry. + * Environment variable and constant are only checked when their + * respective names are provided. * * @since 7.0.0 * @access private * - * @param string $provider_id The provider ID (e.g., 'openai', 'anthropic', 'google'). - * @param string $setting_name The option name for the API key (e.g., 'connectors_ai_openai_api_key'). + * @param string $setting_name The option name for the API key (e.g., 'connectors_spam_filtering_akismet_api_key'). + * @param string $env_var_name Optional. Environment variable name to check (e.g., 'AKISMET_API_KEY'). + * @param string $constant_name Optional. PHP constant name to check (e.g., 'AKISMET_API_KEY'). * @return string The key source: 'env', 'constant', 'database', or 'none'. */ -function _wp_connectors_get_api_key_source( string $provider_id, string $setting_name ): string { - // Convert provider ID to CONSTANT_CASE for env var name. - // e.g., 'openai' -> 'OPENAI', 'anthropic' -> 'ANTHROPIC'. - $constant_case_id = strtoupper( - preg_replace( '/([a-z])([A-Z])/', '$1_$2', str_replace( '-', '_', $provider_id ) ) - ); - $env_var_name = "{$constant_case_id}_API_KEY"; - +function _wp_connectors_get_api_key_source( string $setting_name, string $env_var_name = '', string $constant_name = '' ): string { // Check environment variable first. - $env_value = getenv( $env_var_name ); - if ( false !== $env_value && '' !== $env_value ) { - return 'env'; + if ( '' !== $env_var_name ) { + $env_value = getenv( $env_var_name ); + if ( false !== $env_value && '' !== $env_value ) { + return 'env'; + } } // Check PHP constant. - if ( defined( $env_var_name ) ) { - $const_value = constant( $env_var_name ); + if ( '' !== $constant_name && defined( $constant_name ) ) { + $const_value = constant( $constant_name ); if ( is_string( $const_value ) && '' !== $const_value ) { return 'constant'; } @@ -470,7 +497,7 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } @@ -481,8 +508,9 @@ function _wp_connectors_rest_settings_dispatch( WP_REST_Response $response, WP_R $value = $data[ $setting_name ]; - // On update, validate the key before masking. - if ( $is_update && is_string( $value ) && '' !== $value ) { + // On update, validate AI provider keys before masking. + // Non-AI connectors accept keys as-is; the service plugin handles its own validation. + if ( $is_update && is_string( $value ) && '' !== $value && 'ai_provider' === $connector_data['type'] ) { if ( true !== _wp_connectors_is_ai_api_key_valid( $value, $connector_id ) ) { update_option( $setting_name, '' ); $data[ $setting_name ] = ''; @@ -508,16 +536,22 @@ add_filter( 'rest_post_dispatch', '_wp_connectors_rest_settings_dispatch', 10, 3 * @access private */ function _wp_register_default_connector_settings(): void { - $ai_registry = AiClient::defaultRegistry(); + $ai_registry = AiClient::defaultRegistry(); + $registered_settings = get_registered_settings(); foreach ( wp_get_connectors() as $connector_id => $connector_data ) { $auth = $connector_data['authentication']; - if ( 'ai_provider' !== $connector_data['type'] || 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { + if ( 'api_key' !== $auth['method'] || empty( $auth['setting_name'] ) ) { continue; } - // Skip registering the setting if the provider is not in the registry. - if ( ! $ai_registry->hasProvider( $connector_id ) ) { + // Skip if the setting is already registered (e.g. by an owning plugin). + if ( isset( $registered_settings[ $auth['setting_name'] ] ) ) { + continue; + } + + // For AI providers, skip if the provider is not in the AI Client registry. + if ( 'ai_provider' === $connector_data['type'] && ! $ai_registry->hasProvider( $connector_id ) ) { continue; } @@ -527,13 +561,13 @@ function _wp_register_default_connector_settings(): void { array( 'type' => 'string', 'label' => sprintf( - /* translators: %s: AI provider name. */ + /* translators: %s: Connector name. */ __( '%s API Key' ), $connector_data['name'] ), 'description' => sprintf( - /* translators: %s: AI provider name. */ - __( 'API key for the %s AI provider.' ), + /* translators: %s: Connector name. */ + __( 'API key for the %s connector.' ), $connector_data['name'] ), 'default' => '', @@ -569,7 +603,7 @@ function _wp_connectors_pass_default_keys_to_ai_client(): void { } // Skip if the key is already provided via env var or constant. - $key_source = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ); + $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'], $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' ); if ( 'env' === $key_source || 'constant' === $key_source ) { continue; } @@ -620,11 +654,17 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { if ( 'api_key' === $auth['method'] ) { $auth_out['settingName'] = $auth['setting_name'] ?? ''; $auth_out['credentialsUrl'] = $auth['credentials_url'] ?? null; - $auth_out['keySource'] = _wp_connectors_get_api_key_source( $connector_id, $auth['setting_name'] ?? '' ); - try { - $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); - } catch ( Exception $e ) { - $auth_out['isConnected'] = false; + $key_source = _wp_connectors_get_api_key_source( $auth['setting_name'] ?? '', $auth['env_var_name'] ?? '', $auth['constant_name'] ?? '' ); + $auth_out['keySource'] = $key_source; + + if ( 'ai_provider' === $connector_data['type'] ) { + try { + $auth_out['isConnected'] = $registry->hasProvider( $connector_id ) && $registry->isProviderConfigured( $connector_id ); + } catch ( Exception $e ) { + $auth_out['isConnected'] = false; + } + } else { + $auth_out['isConnected'] = 'none' !== $key_source; } } @@ -645,7 +685,9 @@ function _wp_connectors_get_connector_script_module_data( array $data ): array { $connector_out['plugin'] = array( 'slug' => $plugin_slug, - 'isInstalled' => $is_installed, + 'pluginFile' => $is_installed + ? ( str_ends_with( $plugin_file, '.php' ) ? substr( $plugin_file, 0, -4 ) : $plugin_file ) + : null, 'isActivated' => $is_activated, ); } diff --git a/wp-includes/version.php b/wp-includes/version.php index 4e825b7431..d0a90dc0ee 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '7.1-alpha-62179'; +$wp_version = '7.1-alpha-62180'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.