Commit 23cd8d7546d for woocommerce

commit 23cd8d7546d34ab1063b624349876113b8417867
Author: Alba Rincón <albarin@users.noreply.github.com>
Date:   Thu Apr 30 12:01:34 2026 +0200

    Add configurable endpoint URL to GraphQL settings (#64350)

    Co-authored-by: Nestor Soriano <konamiman@konamiman.com>

diff --git a/plugins/woocommerce/changelog/64350-graphql-settings-endpoint-url b/plugins/woocommerce/changelog/64350-graphql-settings-endpoint-url
new file mode 100644
index 00000000000..e96a2afff42
--- /dev/null
+++ b/plugins/woocommerce/changelog/64350-graphql-settings-endpoint-url
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add a configurable endpoint URL to the GraphQL settings section.
\ No newline at end of file
diff --git a/plugins/woocommerce/src/Internal/Api/GraphQLController.php b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
index 33bcee34456..ecab8a1b243 100644
--- a/plugins/woocommerce/src/Internal/Api/GraphQLController.php
+++ b/plugins/woocommerce/src/Internal/Api/GraphQLController.php
@@ -38,6 +38,24 @@ abstract class GraphQLController {
 	 */
 	public const DEFAULT_MAX_QUERY_COMPLEXITY = 1000;

+	/**
+	 * Default path (relative to /wp-json/) at which the GraphQL route is registered.
+	 *
+	 * Used as the fallback when the {@see Main::OPTION_ENDPOINT_URL} option is
+	 * unset or was stored in an invalid form. See {@see self::get_endpoint_url()}
+	 * for the accessor.
+	 */
+	public const DEFAULT_ENDPOINT_URL = 'wc/graphql';
+
+	/**
+	 * Regex matching one valid path segment of the endpoint URL.
+	 *
+	 * Constrained to the character class WordPress REST routes accept
+	 * (alphanumerics, underscores, hyphens). Shared with {@see Settings::sanitize_endpoint_url()}
+	 * so the UI sanitizer and the controller-side fallback stay in lockstep.
+	 */
+	public const ENDPOINT_URL_SEGMENT_PATTERN = '/^[A-Za-z0-9_\-]+$/';
+
 	/**
 	 * Cached GraphQL schema instance.
 	 *
@@ -86,6 +104,65 @@ abstract class GraphQLController {
 		return $value > 0 ? $value : self::DEFAULT_MAX_QUERY_COMPLEXITY;
 	}

+	/**
+	 * The path (relative to /wp-json/) at which the GraphQL route is registered.
+	 *
+	 * Reads the {@see Main::OPTION_ENDPOINT_URL} store option; falls back to
+	 * {@see self::DEFAULT_ENDPOINT_URL} when the option is unset, empty, or
+	 * fails {@see self::is_valid_endpoint_url()}. The UI already validates on
+	 * save, so this defense-in-depth guard only fires for CLI-set option values.
+	 */
+	public static function get_endpoint_url(): string {
+		$value = trim( (string) get_option( Main::OPTION_ENDPOINT_URL, self::DEFAULT_ENDPOINT_URL ), '/' );
+		if ( ! self::is_valid_endpoint_url( $value ) ) {
+			return self::DEFAULT_ENDPOINT_URL;
+		}
+		return $value;
+	}
+
+	/**
+	 * Whether a value is a valid endpoint URL.
+	 *
+	 * Requires at least two non-empty path segments (so register_rest_route()
+	 * has both a namespace and a route), each matching
+	 * {@see self::ENDPOINT_URL_SEGMENT_PATTERN}. Mirrors the rules enforced on
+	 * save by {@see Settings::sanitize_endpoint_url()}, so values that bypass
+	 * the UI (e.g. CLI-set options) get the same treatment.
+	 *
+	 * @param string $value Endpoint URL with surrounding slashes already stripped.
+	 */
+	private static function is_valid_endpoint_url( string $value ): bool {
+		if ( '' === $value ) {
+			return false;
+		}
+		$parts = explode( '/', $value );
+		if ( count( $parts ) < 2 ) {
+			return false;
+		}
+		foreach ( $parts as $part ) {
+			if ( '' === $part || ! preg_match( self::ENDPOINT_URL_SEGMENT_PATTERN, $part ) ) {
+				return false;
+			}
+		}
+		return true;
+	}
+
+	/**
+	 * Split the endpoint URL into the `[namespace, route]` pair that
+	 * register_rest_route() expects.
+	 *
+	 * The last path segment becomes the route; everything before it becomes
+	 * the namespace. E.g. `wc/v4/graphql` → `['wc/v4', '/graphql']`.
+	 *
+	 * @return array{0: string, 1: string}
+	 */
+	private static function split_endpoint_url(): array {
+		$parts     = explode( '/', self::get_endpoint_url() );
+		$route     = '/' . array_pop( $parts );
+		$namespace = implode( '/', $parts );
+		return array( $namespace, $route );
+	}
+
 	/**
 	 * Register the GraphQL REST route.
 	 */
@@ -94,10 +171,11 @@ abstract class GraphQLController {
 		if ( empty( $methods ) ) {
 			return;
 		}
+		list( $namespace, $route ) = self::split_endpoint_url();

 		register_rest_route(
-			'wc',
-			'/graphql',
+			$namespace,
+			$route,
 			array(
 				'methods'             => $methods,
 				'callback'            => array( $this, 'handle_request' ),
diff --git a/plugins/woocommerce/src/Internal/Api/Main.php b/plugins/woocommerce/src/Internal/Api/Main.php
index 7233afac85a..6b115f4a1bf 100644
--- a/plugins/woocommerce/src/Internal/Api/Main.php
+++ b/plugins/woocommerce/src/Internal/Api/Main.php
@@ -27,6 +27,13 @@ class Main {
 	 */
 	public const OPTION_GET_ENDPOINT_ENABLED = 'woocommerce_graphql_get_endpoint_enabled';

+	/**
+	 * Option name for the "Endpoint URL" setting.
+	 *
+	 * Path (relative to /wp-json/) at which the GraphQL route is registered.
+	 */
+	public const OPTION_ENDPOINT_URL = 'woocommerce_graphql_endpoint_url';
+
 	/**
 	 * Option name for the "Maximum query depth" setting.
 	 *
diff --git a/plugins/woocommerce/src/Internal/Api/Settings.php b/plugins/woocommerce/src/Internal/Api/Settings.php
index 383af7ae0f7..b4e7c0b0e55 100644
--- a/plugins/woocommerce/src/Internal/Api/Settings.php
+++ b/plugins/woocommerce/src/Internal/Api/Settings.php
@@ -23,6 +23,12 @@ class Settings {
 	public function register(): void {
 		add_filter( 'woocommerce_get_sections_advanced', array( $this, 'add_section' ) );
 		add_filter( 'woocommerce_get_settings_advanced', array( $this, 'add_settings' ), 10, 2 );
+		add_filter(
+			'woocommerce_admin_settings_sanitize_option_' . Main::OPTION_ENDPOINT_URL,
+			array( $this, 'sanitize_endpoint_url' ),
+			10,
+			3
+		);
 	}

 	/**
@@ -80,10 +86,70 @@ class Settings {
 				'type'              => 'number',
 				'custom_attributes' => array( 'min' => '1' ),
 			),
+			array(
+				'title'    => __( 'Endpoint URL', 'woocommerce' ),
+				'desc'     => __( 'Path relative to /wp-json/ where the GraphQL endpoint is exposed. Needs at least two segments (namespace/route), e.g. wc/graphql.', 'woocommerce' ),
+				'desc_tip' => true,
+				'id'       => Main::OPTION_ENDPOINT_URL,
+				'default'  => GraphQLController::DEFAULT_ENDPOINT_URL,
+				'type'     => 'text',
+			),
 			array(
 				'type' => 'sectionend',
 				'id'   => 'woocommerce_graphql_options',
 			),
 		);
 	}
+
+	/**
+	 * Validate and normalize the endpoint URL on save.
+	 *
+	 * Rejects empty input and inputs without at least two path segments, since
+	 * register_rest_route() needs both a namespace and a route. Rejects any
+	 * character outside of what WordPress REST routes accept (alphanumerics,
+	 * underscores, hyphens). On rejection, adds a settings error message and
+	 * returns the previously stored value so the option is not overwritten.
+	 *
+	 * @param mixed $value     The sanitized value passed by earlier filters.
+	 * @param array $option    The option config from add_settings().
+	 * @param mixed $raw_value The raw value submitted by the form. Typed as mixed because POST data can be null or an array (e.g. when the field name is submitted as `name[]`).
+	 * @return string
+	 */
+	public function sanitize_endpoint_url( $value, array $option, $raw_value ): string {
+		unset( $value, $option );
+
+		$fallback = (string) get_option( Main::OPTION_ENDPOINT_URL, GraphQLController::DEFAULT_ENDPOINT_URL );
+
+		if ( ! is_string( $raw_value ) ) {
+			return $fallback;
+		}
+
+		$normalized = trim( $raw_value, '/' );
+
+		if ( '' === $normalized ) {
+			\WC_Admin_Settings::add_error( __( 'GraphQL endpoint URL cannot be empty.', 'woocommerce' ) );
+			return $fallback;
+		}
+
+		$parts = explode( '/', $normalized );
+		if ( count( $parts ) < 2 ) {
+			\WC_Admin_Settings::add_error( __( 'GraphQL endpoint URL needs at least two segments, e.g. wc/graphql.', 'woocommerce' ) );
+			return $fallback;
+		}
+
+		foreach ( $parts as $part ) {
+			if ( '' === $part || ! preg_match( GraphQLController::ENDPOINT_URL_SEGMENT_PATTERN, $part ) ) {
+				\WC_Admin_Settings::add_error(
+					sprintf(
+						/* translators: %s: the invalid path segment */
+						__( 'GraphQL endpoint URL segment "%s" contains invalid characters. Use letters, digits, underscores, and hyphens only.', 'woocommerce' ),
+						$part
+					)
+				);
+				return $fallback;
+			}
+		}
+
+		return $normalized;
+	}
 }
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
index 6855f7ad747..ccb4381703f 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/GraphQLControllerTest.php
@@ -45,6 +45,7 @@ class GraphQLControllerTest extends WC_REST_Unit_Test_Case {
 	 */
 	public function tearDown(): void {
 		delete_option( Main::OPTION_GET_ENDPOINT_ENABLED );
+		delete_option( Main::OPTION_ENDPOINT_URL );
 		delete_option( Main::OPTION_MAX_QUERY_DEPTH );
 		delete_option( Main::OPTION_MAX_QUERY_COMPLEXITY );
 		parent::tearDown();
@@ -80,6 +81,70 @@ class GraphQLControllerTest extends WC_REST_Unit_Test_Case {
 		$this->assertTrue( $methods['POST'] ?? false );
 	}

+	/**
+	 * @testdox get_endpoint_url returns the default when the option is unset.
+	 */
+	public function test_get_endpoint_url_returns_default_when_option_unset(): void {
+		delete_option( Main::OPTION_ENDPOINT_URL );
+		$this->assertSame( GraphQLController::DEFAULT_ENDPOINT_URL, GraphQLController::get_endpoint_url() );
+	}
+
+	/**
+	 * @testdox get_endpoint_url returns the stored option value when it is well-formed.
+	 */
+	public function test_get_endpoint_url_returns_option_value_when_valid(): void {
+		update_option( Main::OPTION_ENDPOINT_URL, 'wc/v4/graphql' );
+		$this->assertSame( 'wc/v4/graphql', GraphQLController::get_endpoint_url() );
+	}
+
+	/**
+	 * @testdox get_endpoint_url strips surrounding slashes from the stored option.
+	 */
+	public function test_get_endpoint_url_strips_surrounding_slashes(): void {
+		update_option( Main::OPTION_ENDPOINT_URL, '/wc/v4/graphql/' );
+		$this->assertSame( 'wc/v4/graphql', GraphQLController::get_endpoint_url() );
+	}
+
+	/**
+	 * @testdox get_endpoint_url falls back to the default when the option has fewer than two segments.
+	 * @dataProvider provider_invalid_endpoint_url_values
+	 *
+	 * @param string $value The invalid option value.
+	 */
+	public function test_get_endpoint_url_falls_back_on_invalid( string $value ): void {
+		update_option( Main::OPTION_ENDPOINT_URL, $value );
+		$this->assertSame( GraphQLController::DEFAULT_ENDPOINT_URL, GraphQLController::get_endpoint_url() );
+	}
+
+	/**
+	 * @testdox register uses the configured endpoint URL when the option is set.
+	 */
+	public function test_register_uses_configured_endpoint_url(): void {
+		update_option( Main::OPTION_ENDPOINT_URL, 'wc/v4/graphql' );
+
+		$this->sut->register();
+
+		$routes = rest_get_server()->get_routes();
+		$this->assertArrayHasKey( '/wc/v4/graphql', $routes, 'The configured endpoint URL should be registered.' );
+		$this->assertArrayNotHasKey( '/wc/graphql', $routes, 'The default endpoint URL should not be registered when a custom one is set.' );
+	}
+
+	/**
+	 * Invalid values that the getter should replace with the default.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_invalid_endpoint_url_values(): array {
+		return array(
+			'empty string'         => array( '' ),
+			'slashes only'         => array( '///' ),
+			'single segment'       => array( 'graphql' ),
+			'empty middle segment' => array( 'wc//graphql' ),
+			'invalid character'    => array( 'wc/graph*ql' ),
+			'space in segment'     => array( 'wc/my graphql' ),
+		);
+	}
+
 	/**
 	 * @testdox get_max_query_depth returns the default when the option is unset.
 	 */
diff --git a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
index 5f91d1f98f7..79fea62814c 100644
--- a/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
+++ b/plugins/woocommerce/tests/php/src/Internal/Api/SettingsTest.php
@@ -30,9 +30,17 @@ class SettingsTest extends WC_Unit_Test_Case {
 	}

 	/**
-	 * Clean up filters registered by tests so global state doesn't leak.
+	 * Clean up filters and options registered by tests so global state doesn't leak.
 	 */
 	public function tearDown(): void {
+		remove_filter( 'woocommerce_get_sections_advanced', array( $this->sut, 'add_section' ) );
+		remove_filter( 'woocommerce_get_settings_advanced', array( $this->sut, 'add_settings' ), 10 );
+		remove_filter(
+			'woocommerce_admin_settings_sanitize_option_' . Main::OPTION_ENDPOINT_URL,
+			array( $this->sut, 'sanitize_endpoint_url' ),
+			10
+		);
+		delete_option( Main::OPTION_ENDPOINT_URL );
 		$this->enable_or_disable_feature( false );
 		parent::tearDown();
 	}
@@ -87,6 +95,103 @@ class SettingsTest extends WC_Unit_Test_Case {
 		$this->assertSame( 'yes', $by_id[ Main::OPTION_GET_ENDPOINT_ENABLED ]['default'] );
 	}

+	/**
+	 * @testdox add_settings defines the endpoint URL text field with the default constant as default.
+	 */
+	public function test_add_settings_defines_endpoint_url_field(): void {
+		$fields = $this->sut->add_settings( array(), Settings::SECTION_ID );
+		$by_id  = array_column( $fields, null, 'id' );
+
+		$this->assertArrayHasKey( Main::OPTION_ENDPOINT_URL, $by_id );
+		$this->assertSame( 'text', $by_id[ Main::OPTION_ENDPOINT_URL ]['type'] );
+		$this->assertSame( GraphQLController::DEFAULT_ENDPOINT_URL, $by_id[ Main::OPTION_ENDPOINT_URL ]['default'] );
+	}
+
+	/**
+	 * @testdox sanitize_endpoint_url returns the normalized input for a well-formed URL.
+	 */
+	public function test_sanitize_endpoint_url_accepts_valid_url(): void {
+		$result = $this->sut->sanitize_endpoint_url( null, array(), 'wc/v4/graphql' );
+		$this->assertSame( 'wc/v4/graphql', $result );
+	}
+
+	/**
+	 * @testdox sanitize_endpoint_url strips surrounding slashes.
+	 */
+	public function test_sanitize_endpoint_url_strips_surrounding_slashes(): void {
+		$result = $this->sut->sanitize_endpoint_url( null, array(), '/wc/v4/graphql/' );
+		$this->assertSame( 'wc/v4/graphql', $result );
+	}
+
+	/**
+	 * @testdox sanitize_endpoint_url rejects invalid input and returns the previously stored value.
+	 * @dataProvider provider_invalid_endpoint_url_inputs
+	 *
+	 * @param string $raw_input The raw submitted value.
+	 */
+	public function test_sanitize_endpoint_url_rejects_invalid_input( string $raw_input ): void {
+		update_option( Main::OPTION_ENDPOINT_URL, 'wc/v4/graphql' );
+
+		$result = $this->sut->sanitize_endpoint_url( null, array(), $raw_input );
+
+		$this->assertSame( 'wc/v4/graphql', $result, 'Invalid input should not overwrite the previously stored value.' );
+	}
+
+	/**
+	 * Inputs the sanitize handler should reject.
+	 *
+	 * @return array<string, array{string}>
+	 */
+	public function provider_invalid_endpoint_url_inputs(): array {
+		return array(
+			'empty string'       => array( '' ),
+			'slashes only'       => array( '///' ),
+			'single segment'     => array( 'graphql' ),
+			'spaces in segment'  => array( 'wc/my graphql' ),
+			'special characters' => array( 'wc/graph*ql' ),
+		);
+	}
+
+	/**
+	 * @testdox sanitize_endpoint_url falls back to the stored value when the raw input is not a string.
+	 * @dataProvider provider_non_string_endpoint_url_inputs
+	 *
+	 * @param mixed $raw_input The raw submitted value (null, array, etc.).
+	 */
+	public function test_sanitize_endpoint_url_handles_non_string_input( $raw_input ): void {
+		update_option( Main::OPTION_ENDPOINT_URL, 'wc/v4/graphql' );
+
+		$result = $this->sut->sanitize_endpoint_url( null, array(), $raw_input );
+
+		$this->assertSame( 'wc/v4/graphql', $result, 'Non-string input should not overwrite the previously stored value.' );
+	}
+
+	/**
+	 * Non-string raw inputs the sanitize handler may receive from POST data.
+	 *
+	 * @return array<string, array{mixed}>
+	 */
+	public function provider_non_string_endpoint_url_inputs(): array {
+		return array(
+			'null'  => array( null ),
+			'array' => array( array( 'wc/graphql' ) ),
+		);
+	}
+
+	/**
+	 * @testdox add_settings returns the input unchanged on PHP < 8.1.
+	 */
+	public function test_add_settings_is_noop_on_unsupported_php(): void {
+		if ( PHP_VERSION_ID >= 80100 ) {
+			$this->markTestSkipped( 'Only relevant on PHP < 8.1.' );
+		}
+
+		$input  = array( array( 'id' => 'existing' ) );
+		$result = $this->sut->add_settings( $input, Settings::SECTION_ID );
+
+		$this->assertSame( $input, $result );
+	}
+
 	/**
 	 * @testdox add_settings defines the max query depth field with min=1 and the default constant as default.
 	 */