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.
*/