PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.1
WooCommerce v10.9.1
10.9.3 10.9.2 10.9.1 10.9.0 10.9.0-rc.1 10.9.0-beta.2 10.9.0-beta.1 10.8.1 10.8.0 10.8.0-rc.1 10.8.0-beta.2 10.8.0-beta.1 7.8.0-beta.1 7.8.0-beta.2 7.8.0-rc.1 7.8.0-rc.2 7.8.1 7.8.2 7.8.3 7.8.4 7.9.0 7.9.0-beta.1 7.9.0-beta.2 7.9.0-rc.2 7.9.0-rc.3 7.9.1 7.9.2 8.0.0 8.0.0-beta.1 8.0.0-beta.2 8.0.0-rc.1 8.0.0-rc.2 8.0.1 8.0.2 8.0.3 8.0.4 8.0.5 8.1.0 8.1.0-beta.1 8.1.0-rc.1 8.1.0-rc.2 8.1.1 8.1.2 8.1.3 8.1.4 8.2.0 8.2.0-beta.1 8.2.0-rc.1 8.2.0-rc.2 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.3.0 8.3.0-beta.1 8.3.0-rc.1 8.3.0-rc.2 8.3.1 8.3.2 8.3.3 8.3.4 8.4.0 8.4.0-beta.1 8.4.0-rc.1 8.4.1 8.4.2 8.4.3 8.5.0 8.5.0-beta.1 8.5.0-rc.1 8.5.1 8.5.2 8.5.3 8.5.4 8.5.5 8.6.0 8.6.0-beta.1 8.6.0-rc.1 8.6.1 8.6.2 8.6.3 8.6.4 8.7.0 8.7.0-beta.1 8.7.0-beta.2 8.7.0-rc.1 8.7.1 8.7.2 8.7.3 8.8.0 8.8.0-beta.1 8.8.0-rc.1 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.8.6 8.8.7 8.9.0 8.9.0-beta.1 8.9.0-rc.1 8.9.1 8.9.2 8.9.3 8.9.4 8.9.5 9.0.0 9.0.0-beta.1 9.0.0-beta.2 9.0.0-rc.1 9.0.1 9.0.2 9.0.3 9.0.4 9.1.0 9.1.0-beta.1 9.1.0-rc.1 9.1.1 9.1.2 9.1.3 9.1.4 9.1.5 9.1.6 9.2.0 9.2.0-beta.1 9.2.0-rc.1 9.2.1 9.2.2 9.2.3 9.2.4 9.2.5 9.3.0 9.3.0-beta.1 9.3.0-rc.1 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.3.6 9.4.0 9.4.0-beta.1 9.4.0-beta.2 9.4.0-rc.1 9.4.0-rc.2 9.4.0-rc.3 9.4.0-rc.4 9.4.1 9.4.2 9.4.3 9.4.4 9.4.5 9.5.0 9.5.0-beta.1 9.5.0-beta.2 9.5.0-rc.1 9.5.1 9.5.2 9.5.3 9.5.4 9.6.0 9.6.0-beta.1 9.6.0-beta.2 9.6.0-rc.1 9.6.1 9.6.2 9.6.3 9.6.4 9.7.0 9.7.0-beta.1 9.7.0-rc.1 9.7.1 9.7.2 9.7.3 9.8.0 9.8.0-beta.1 9.8.0-rc.1 9.8.1 9.8.2 9.8.3 9.8.4 9.8.5 9.8.6 9.8.7 9.9.0 9.9.0-beta.1 9.9.0-rc.1 9.9.1 9.9.2 9.9.3 9.9.4 9.9.5 9.9.6 9.9.7 3.7.3 7.1.2 3.8.0 7.2.0 3.8.0-beta.1 7.2.0-beta.1 3.8.0-rc.1 7.2.0-beta.2 3.8.0-rc.2 7.2.0-rc.1 3.8.1 7.2.0-rc.2 3.8.2 7.2.1 3.8.3 7.2.2 3.9.0 7.2.3 3.9.0-beta.1 7.2.4 3.9.0-beta.2 7.3.0 3.9.0-rc.1 7.3.0-beta.1 3.9.0-rc.2 7.3.0-beta.2 3.9.0-rc.3 7.3.0-rc.1 3.9.0-rc.4 7.3.0-rc.2 3.9.1 7.3.1 3.9.2 7.4.0 3.9.3 7.4.0-beta.1 3.9.4 7.4.0-beta.2 3.9.5 7.4.0-rc.1 4.0.0 7.4.0-rc.2 4.0.0-beta.1 7.4.1 4.0.0-rc.1 7.4.2 4.0.0-rc.2 7.5.0 4.0.1 7.5.0-beta.1 4.0.2 7.5.0-beta.2 4.0.3 7.5.0-rc.1 4.0.4 7.5.1 4.1.0 7.5.2 4.1.0-beta.1 7.6.0 4.1.0-beta.2 7.6.0-beta.1 4.1.0-rc.1 7.6.0-beta.2 4.1.0-rc.2 7.6.0-rc.1 4.1.1 7.6.0-rc.2 4.1.2 7.6.0-rc.3 4.1.3 7.6.1 4.1.4 7.6.2 4.2.0 7.7.0 4.2.0-RC.1 7.7.0-beta.1 4.2.0-RC.2 7.7.0-beta.2 4.2.0-beta.1 7.7.0-rc.1 4.2.1 7.7.1 4.2.2 7.7.2 4.2.3 7.7.3 4.2.4 7.8.0 4.2.5 4.3.0 4.3.0-beta.1 4.3.0-rc.1 4.3.0-rc.2 4.3.0-rc.3 4.3.1 4.3.2 4.3.3 4.3.4 4.3.5 4.3.6 4.4.0 4.4.0-beta.1 4.4.0-rc.1 4.4.1 4.4.2 4.4.3 4.4.4 4.5.0 4.5.0-beta.1 4.5.0-rc.1 4.5.0-rc.3 4.5.1 4.5.2 4.5.3 4.5.4 4.5.5 4.6.0 4.6.0-beta.1 4.6.0-rc.1 4.6.1 4.6.2 4.6.3 4.6.4 4.6.5 4.7.0 4.7.0-beta.1 4.7.0-beta.2 4.7.0-rc.1 4.7.1 4.7.1-beta.1 4.7.2 4.7.3 4.7.4 4.8.0 4.8.0-beta.1 4.8.0-rc.1 4.8.0-rc.2 4.8.1 4.8.2 4.8.3 4.9.0 4.9.0-beta.1 4.9.0-rc.1 4.9.0-rc.2 4.9.1 4.9.2 4.9.3 4.9.4 4.9.5 5.0.0 5.0.0-beta.1 5.0.0-beta.2 5.0.0-rc.1 5.0.0-rc.2 5.0.0-rc.3 5.0.1 5.0.2 5.0.3 5.1.0 5.1.0-beta.1 5.1.0-rc.1 trunk 5.1.1 10.0.0 5.1.2 10.0.0-rc.1 5.1.3 10.0.0-rc.2 5.2.0 10.0.1 5.2.0-beta.1 10.0.2 5.2.0-rc.1 10.0.3 5.2.0-rc.2 10.0.4 5.2.1 10.0.5 5.2.2 10.0.6 5.2.3 10.1.0 5.2.4 10.1.0-rc.1 5.2.5 10.1.0-rc.2 5.3.0 10.1.0-rc.3 5.3.0-beta.1 10.1.0-rc.4 5.3.0-rc.1 10.1.1 5.3.0-rc.2 10.1.2 5.3.1 10.1.3 5.3.2 10.1.4 5.3.3 10.2.0 5.4.0 10.2.0-beta.1 5.4.0-beta.1 10.2.0-beta.2 5.4.0-rc.1 10.2.0-rc.1 5.4.1 10.2.1 5.4.2 10.2.2 5.4.3 10.2.3 5.4.4 10.2.4 5.4.5 10.3.0 5.5.0 10.3.0-beta.1 5.5.0-beta.1 10.3.0-beta.2 5.5.0-rc.1 10.3.0-rc.1 5.5.0-rc.2 10.3.0-rc.2 5.5.1 10.3.1 5.5.2 10.3.2 5.5.3 10.3.3 5.5.4 10.3.4 5.5.5 10.3.5 5.6.0 10.3.6 5.6.0-beta.1 10.3.7 5.6.0-rc.1 10.3.8 5.6.0-rc.2 10.4.0 5.6.1 10.4.0-beta.1 5.6.2 10.4.0-beta.2 5.6.3 10.4.0-rc.1 5.7.0 10.4.1 5.7.0-beta.1 10.4.2 5.7.0-rc.1 10.4.3 5.7.1 10.4.4 5.7.2 10.5.0 5.7.3 10.5.0-beta.1 5.8.0 10.5.0-beta.2 5.8.0-beta.1 10.5.0-rc.1 5.8.0-beta.2 10.5.0-rc.2 5.8.0-rc.1 10.5.0-rc.3 5.8.1 10.5.1 5.8.2 10.5.2 5.9.0 10.5.3 5.9.0-beta.1 10.6.0 5.9.0-rc.1 10.6.0-beta.1 5.9.0-rc.2 10.6.0-beta.2 5.9.1 10.6.0-rc.1 5.9.2 10.6.1 6.0.0 10.6.2 6.0.0-beta.1 10.7.0 6.0.0-rc.1 10.7.0-beta.1 6.0.1 10.7.0-beta.2 6.0.2 10.7.0-rc.1 6.1.0 3.0.0 6.1.0-beta.1 3.0.1 6.1.0-rc.1 3.0.2 6.1.0-rc.2 3.0.3 6.1.1 3.0.4 6.1.2 3.0.5 6.1.3 3.0.6 6.2.0 3.0.7 6.2.0-beta.1 3.0.8 6.2.0-rc.1 3.0.9 6.2.0-rc.2 3.1.0 6.2.1 3.1.1 6.2.2 3.1.2 6.2.3 3.2.0 6.3.0 3.2.1 6.3.0-beta.1 3.2.2 6.3.0-rc.1 3.2.3 6.3.0-rc.2 3.2.4 6.3.1 3.2.5 6.3.2 3.2.6 6.4.0 3.3.0 6.4.0-beta.1 3.3.1 6.4.0-rc.1 3.3.2 6.4.1 3.3.2-rc.1 6.4.2 3.3.3 6.5.0 3.3.4 6.5.0-beta.1 3.3.5 6.5.0-rc.1 3.3.6 6.5.0-rc.2 3.4.0 6.5.1 3.4.0-beta.1 6.5.2 3.4.0-rc.2 6.6.0 3.4.1 6.6.0-beta.1 3.4.2 6.6.0-rc.1 3.4.3 6.6.0-rc.2 3.4.4 6.6.1 3.4.5 6.6.2 3.4.6 6.7.0 3.4.7 6.7.0-beta.1 3.4.8 6.7.0-beta.2 3.5.0 6.7.0-rc.1 3.5.0-beta.1 6.7.1 3.5.0-rc.1 6.8.0 3.5.0-rc.2 6.8.0-beta.1 3.5.1 6.8.0-beta.2 3.5.10 6.8.0-rc.1 3.5.2 6.8.1 3.5.3 6.8.2 3.5.4 6.8.3 3.5.5 6.9.0 3.5.6 6.9.0-beta.1 3.5.7 6.9.0-beta.2 3.5.8 6.9.0-rc.1 3.5.9 6.9.1 3.6.0 6.9.2 3.6.0-beta.1 6.9.3 3.6.0-rc.1 6.9.4 3.6.0-rc.2 6.9.5 3.6.0-rc.3 7.0.0 3.6.1 7.0.0-beta.1 3.6.2 7.0.0-beta.2 3.6.3 7.0.0-beta.3 3.6.4 7.0.0-rc.1 3.6.5 7.0.0-rc.2 3.6.6 7.0.1 3.6.7 7.0.2 3.7.0 7.1.0 3.7.0-beta.1 7.1.0-beta.1 3.7.0-rc.1 7.1.0-beta.2 3.7.0-rc.2 7.1.0-rc.1 3.7.1 7.1.0-rc.2 3.7.2 7.1.1
woocommerce / src / Api / Infrastructure / Main.php
woocommerce / src / Api / Infrastructure Last commit date
Schema 4 weeks ago ClassResolver.php 4 weeks ago GraphQLControllerBase.php 4 weeks ago Main.php 4 weeks ago MetadataController.php 4 weeks ago Principal.php 4 weeks ago PrincipalResolver.php 4 weeks ago QueryInfoExtractor.php 4 weeks ago ResolverHelpers.php 4 weeks ago
Main.php
416 lines
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Automattic\WooCommerce\Api\Infrastructure;
6
7 use Automattic\WooCommerce\Internal\Api\Autogenerated;
8 use Automattic\WooCommerce\Internal\Api\GraphQLEndpointRegistrar;
9 use Automattic\WooCommerce\Internal\Api\OpcacheFileExpiry;
10 use Automattic\WooCommerce\Internal\Api\QueryCache;
11 use Automattic\WooCommerce\Internal\Api\Settings;
12 use Automattic\WooCommerce\Utilities\FeaturesUtil;
13
14 /**
15 * Entry point for the WooCommerce GraphQL API.
16 *
17 * This class is intentionally free of PHP 8.0+ syntax so that it can be
18 * loaded and called on PHP 7.4 without parse errors. The PHP-8.1-only
19 * classes (GraphQLControllerBase, QueryCache, etc.) are resolved lazily
20 * from the DI container only after is_enabled() confirms PHP 8.1+ is
21 * available.
22 */
23 class Main {
24 /**
25 * Feature flag slug registered in FeaturesController.
26 */
27 private const FEATURE_SLUG = 'dual_code_graphql_api';
28
29 /**
30 * Option name for the "Enable GET endpoint" setting.
31 *
32 * When disabled, the GraphQL route only accepts POST requests.
33 */
34 public const OPTION_GET_ENDPOINT_ENABLED = 'woocommerce_graphql_get_endpoint_enabled';
35
36 /**
37 * Option name for the "Enable APQ caching" setting.
38 *
39 * When disabled, the persistedQuery extension is ignored and requests are
40 * treated as standard queries.
41 */
42 public const OPTION_APQ_ENABLED = 'woocommerce_graphql_apq_enabled';
43
44 /**
45 * Option name for the "Endpoint URL" setting.
46 *
47 * Path (relative to /wp-json/) at which the GraphQL route is registered.
48 */
49 public const OPTION_ENDPOINT_URL = 'woocommerce_graphql_endpoint_url';
50
51 /**
52 * Option name for the "Maximum query depth" setting.
53 *
54 * Caps how deep the selection tree of a GraphQL query may nest.
55 */
56 public const OPTION_MAX_QUERY_DEPTH = 'woocommerce_graphql_max_query_depth';
57
58 /**
59 * Option name for the "Maximum query complexity" setting.
60 *
61 * Caps the computed complexity score of a GraphQL query — connection
62 * fields multiply their children's cost by the requested page size.
63 */
64 public const OPTION_MAX_QUERY_COMPLEXITY = 'woocommerce_graphql_max_query_complexity';
65
66 /**
67 * Option name for the "OPcache-based caching" setting.
68 *
69 * When enabled, parsed query ASTs are written to disk as PHP files so
70 * that OPcache serves them from shared memory on subsequent requests.
71 */
72 public const OPTION_OPCACHE_ENABLED = 'woocommerce_graphql_opcache_enabled';
73
74 /**
75 * Option name for the "ObjectCache-based caching" setting.
76 */
77 public const OPTION_OBJECT_CACHE_ENABLED = 'woocommerce_graphql_object_cache_enabled';
78
79 /**
80 * Option name for the "Parsed query cache TTL" setting.
81 *
82 * Time-to-live (in seconds) applied to parsed-query AST entries written
83 * to the WP object cache by both the standard-query and APQ paths.
84 */
85 public const OPTION_QUERY_CACHE_TTL = 'woocommerce_graphql_query_cache_ttl';
86
87 /**
88 * Check whether the Dual Code & GraphQL API feature is active.
89 *
90 * Requires PHP 8.1+ and the dual_code_graphql_api feature flag to be
91 * enabled.
92 *
93 * @return bool
94 */
95 public static function is_enabled(): bool {
96 return PHP_VERSION_ID >= 80100 && FeaturesUtil::feature_is_enabled( self::FEATURE_SLUG );
97 }
98
99 /**
100 * Whether the GraphQL endpoint accepts GET requests.
101 *
102 * Defaults to false. Reads from the option written by the GraphQL
103 * settings section so the REST route registration can decide which
104 * HTTP methods to accept.
105 */
106 public static function is_get_endpoint_enabled(): bool {
107 return wc_string_to_bool( get_option( self::OPTION_GET_ENDPOINT_ENABLED, 'yes' ) );
108 }
109
110 /**
111 * Whether the Apollo Automatic Persisted Queries (APQ) protocol is enabled.
112 *
113 * Defaults to true. When disabled, the `persistedQuery` request extension
114 * is ignored and requests are processed as standard (non-persisted) queries.
115 */
116 public static function is_apq_enabled(): bool {
117 return wc_string_to_bool( get_option( self::OPTION_APQ_ENABLED, 'yes' ) );
118 }
119
120 /**
121 * Whether the OPcache-backed query cache is enabled.
122 *
123 * Defaults to true. Activation also depends on OPcache being loaded and
124 * the cache directory being writable; see {@see QueryCache} for the
125 * runtime capability check.
126 */
127 public static function is_opcache_enabled(): bool {
128 return wc_string_to_bool( get_option( self::OPTION_OPCACHE_ENABLED, 'yes' ) );
129 }
130
131 /**
132 * Whether the ObjectCache-backed query cache is enabled.
133 *
134 * Defaults to true.
135 */
136 public static function is_object_cache_enabled(): bool {
137 return wc_string_to_bool( get_option( self::OPTION_OBJECT_CACHE_ENABLED, 'yes' ) );
138 }
139
140 /**
141 * Apply the GraphQL-scoped site settings to a caller-declared list of HTTP
142 * methods.
143 *
144 * Centralises the "what verbs does the admin actually allow on a GraphQL
145 * endpoint" rule so both WooCommerce core's own endpoint and every sibling
146 * plugin endpoint applied through {@see self::register_graphql_endpoint()}
147 * honour the same settings.
148 *
149 * Currently the only rule is: if the GET endpoint has been disabled in the
150 * GraphQL settings section, strip GET from the list.
151 *
152 * @param string[] $methods HTTP methods the caller declared.
153 * @return string[] Possibly narrowed list — may be empty, in which case the
154 * caller should skip the route registration entirely.
155 */
156 public static function filter_methods_against_settings( array $methods ): array {
157 if ( ! self::is_get_endpoint_enabled() ) {
158 $methods = array_values( array_diff( $methods, array( 'GET' ) ) );
159 }
160 return $methods;
161 }
162
163 /**
164 * Register the GraphQL endpoint when the feature is active.
165 *
166 * When the feature is off this is a no-op. Classes in the public
167 * Automattic\WooCommerce\Api\ namespace remain autoloadable — extensions
168 * that want to know whether the feature is active should check
169 * FeaturesUtil::feature_is_enabled( 'dual_code_graphql_api' ) rather
170 * than class_exists() on the Api namespace.
171 *
172 * The feature-enabled check is deferred to the `rest_api_init` callback
173 * to avoid triggering translation loading (via FeaturesController) before
174 * the `init` action has fired, which would cause a
175 * `_load_textdomain_just_in_time` notice on WordPress 6.7+.
176 */
177 public static function register(): void {
178 add_action( 'rest_api_init', array( self::class, 'handle_rest_api_init_for_core' ) );
179
180 $settings = wc_get_container()->get( Settings::class );
181 $settings->register();
182
183 add_action( OpcacheFileExpiry::ACTION_HOOK, array( OpcacheFileExpiry::class, 'handle_cleanup_action' ) );
184 }
185
186 /**
187 * Hook callback: register WooCommerce core's GraphQL endpoint.
188 *
189 * @internal
190 */
191 public static function handle_rest_api_init_for_core(): void {
192 if ( ! self::is_enabled() ) {
193 return;
194 }
195
196 wc_get_container()->get( Autogenerated\GraphQLController::class )->register();
197 }
198
199 /**
200 * Instantiate a GraphQL controller subclass and wire up its dependencies.
201 *
202 * Intended for sibling WooCommerce plugins that ship their own
203 * autogenerated GraphQLController subclass (emitted by build-api.php
204 * into their own autogenerated namespace). The returned controller is
205 * ready to have handle_request() attached to a REST route.
206 *
207 * Returns null when the feature flag is off or PHP is < 8.1, so callers
208 * can invoke this unconditionally from inside their own rest_api_init
209 * handler.
210 *
211 * @param string $controller_class_name Fully-qualified name of a subclass of GraphQLControllerBase.
212 *
213 * @throws \InvalidArgumentException If $controller_class_name does not extend GraphQLControllerBase.
214 */
215 public static function instantiate_graphql_controller( string $controller_class_name ): ?GraphQLControllerBase {
216 if ( ! self::is_enabled() ) {
217 return null;
218 }
219
220 self::assert_is_controller_subclass( $controller_class_name );
221
222 $controller = new $controller_class_name();
223 $controller->init( wc_get_container()->get( QueryCache::class ) );
224 return $controller;
225 }
226
227 /**
228 * Register a GraphQL REST endpoint backed by a plugin-provided controller subclass.
229 *
230 * May be called at any time up to and including the `rest_api_init` hook:
231 * if called earlier, registration is deferred to that hook; if called from
232 * inside another plugin's `rest_api_init` handler, registration happens
233 * immediately. Calls made after `rest_api_init` has already completed
234 * register a REST route that WP_REST_Server won't honour on the current
235 * request — callers should avoid deferring registration past bootstrap.
236 *
237 * When the feature flag is off or PHP is < 8.1 this is a silent no-op.
238 *
239 * The first argument accepts either of two forms:
240 *
241 * - A plugin root directory (recommended): pass `__DIR__` from the plugin's
242 * bootstrap file. The controller class is resolved by convention from
243 * `{dir}/src/Internal/Api/Autogenerated/GraphQLController.php` — ApiBuilder
244 * always emits the generated subclass at that path.
245 * - A controller class FQCN: use this when the plugin keeps its generated
246 * code somewhere other than the conventional location, or registers
247 * multiple endpoints backed by different controller classes.
248 *
249 * Plugins calling this method should guard the call with method_exists():
250 *
251 * if ( method_exists( \Automattic\WooCommerce\Api\Infrastructure\Main::class, 'register_graphql_endpoint' ) ) {
252 * \Automattic\WooCommerce\Api\Infrastructure\Main::register_graphql_endpoint(
253 * __DIR__,
254 * 'my-plugin',
255 * '/graphql'
256 * );
257 * }
258 *
259 * @param string $plugin_dir_or_controller_class Plugin root directory, OR a fully-qualified GraphQLController subclass name. See above.
260 * @param string $route_namespace REST route namespace, as passed to register_rest_route().
261 * @param string $route REST route path, as passed to register_rest_route().
262 * @param string[] $methods HTTP methods accepted on the endpoint. Defaults to GET + POST.
263 *
264 * @throws \InvalidArgumentException If no controller can be resolved from a directory argument, or if the resolved class does not extend GraphQLControllerBase.
265 */
266 public static function register_graphql_endpoint(
267 string $plugin_dir_or_controller_class,
268 string $route_namespace,
269 string $route,
270 array $methods = array( 'GET', 'POST' )
271 ): void {
272 if ( ! self::is_enabled() ) {
273 return;
274 }
275
276 $controller_class_name = self::resolve_controller_class( $plugin_dir_or_controller_class );
277
278 // Validate up front so a typo surfaces here at bootstrap rather than
279 // deep inside the deferred rest_api_init callback.
280 self::assert_is_controller_subclass( $controller_class_name );
281
282 $registrar = new GraphQLEndpointRegistrar( $controller_class_name, $route_namespace, $route, $methods );
283
284 if ( did_action( 'rest_api_init' ) ) {
285 $registrar->handle_rest_api_init();
286 } else {
287 add_action( 'rest_api_init', array( $registrar, 'handle_rest_api_init' ) );
288 }
289 }
290
291 /**
292 * Resolve the first argument of {@see self::register_graphql_endpoint()} to a
293 * controller class name. If the argument is an existing directory, treat it
294 * as the plugin root and read the namespace from the generated controller
295 * file at the conventional path. Otherwise return the argument unchanged so
296 * it's used as a class FQCN.
297 *
298 * @param string $arg Either a plugin root directory or a controller class FQCN.
299 *
300 * @throws \InvalidArgumentException If the argument is a directory but the
301 * generated controller file is missing or
302 * doesn't declare a PHP namespace.
303 */
304 private static function resolve_controller_class( string $arg ): string {
305 if ( ! is_dir( $arg ) ) {
306 return $arg;
307 }
308
309 $controller_file = rtrim( $arg, '/\\' ) . '/src/Internal/Api/Autogenerated/GraphQLController.php';
310 if ( ! is_file( $controller_file ) ) {
311 throw new \InvalidArgumentException(
312 sprintf(
313 'Expected a generated GraphQL controller at %s, but the file does not exist. Run the plugin\'s API build script first, or pass an explicit controller class name.',
314 esc_html( $controller_file )
315 )
316 );
317 }
318
319 // The generated controller always declares its namespace near the top
320 // of the file; reading the first few KB is more than enough. Reading a
321 // local file — not a URL — so wp_remote_get() does not apply here.
322 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
323 $head = file_get_contents( $controller_file, false, null, 0, 4096 );
324 if ( false === $head ) {
325 throw new \InvalidArgumentException(
326 sprintf( 'Could not read the controller file at %s.', esc_html( $controller_file ) )
327 );
328 }
329
330 $namespace = self::extract_namespace_from_php_source( $head );
331 if ( null === $namespace ) {
332 throw new \InvalidArgumentException(
333 sprintf( 'Could not determine the PHP namespace of the controller at %s.', esc_html( $controller_file ) )
334 );
335 }
336
337 return $namespace . '\\GraphQLController';
338 }
339
340 /**
341 * Extract an unbracketed `namespace …;` declaration from a PHP source fragment.
342 *
343 * Uses PHP's tokenizer rather than a regex so the parse isn't fooled by
344 * declarations inside heredocs, comments, or bracketed-namespace syntax,
345 * and continues to work if the generator's output format changes
346 * (e.g. attributes before the declaration, single-line `<?php namespace`).
347 *
348 * @param string $source PHP source code.
349 * @return ?string The namespace FQN without leading or trailing separator, or null if none found.
350 */
351 private static function extract_namespace_from_php_source( string $source ): ?string {
352 $tokens = token_get_all( $source );
353 $count = count( $tokens );
354 for ( $i = 0; $i < $count; $i++ ) {
355 if ( ! is_array( $tokens[ $i ] ) || T_NAMESPACE !== $tokens[ $i ][0] ) {
356 continue;
357 }
358 $namespace = '';
359 for ( $j = $i + 1; $j < $count; $j++ ) {
360 $token = $tokens[ $j ];
361 if ( is_array( $token ) ) {
362 // T_NAME_QUALIFIED and T_NAME_FULLY_QUALIFIED exist on PHP 8+ and already
363 // contain the full namespace path; T_STRING / T_NS_SEPARATOR are the
364 // equivalent pieces on older versions.
365 if ( in_array( $token[0], array( T_STRING, T_NS_SEPARATOR ), true )
366 || ( defined( 'T_NAME_QUALIFIED' ) && T_NAME_QUALIFIED === $token[0] )
367 || ( defined( 'T_NAME_FULLY_QUALIFIED' ) && T_NAME_FULLY_QUALIFIED === $token[0] ) ) {
368 $namespace .= $token[1];
369 continue;
370 }
371 if ( T_WHITESPACE === $token[0] ) {
372 continue;
373 }
374 }
375 // Single-character tokens `;` (unbracketed namespace) and `{` (bracketed) end the declaration.
376 break;
377 }
378 $namespace = trim( $namespace, '\\' );
379 if ( '' !== $namespace ) {
380 return $namespace;
381 }
382 }
383 return null;
384 }
385
386 /**
387 * Assert that a class name resolves to a concrete GraphQLControllerBase subclass.
388 *
389 * @param string $controller_class_name Fully-qualified name of the class to validate.
390 *
391 * @throws \InvalidArgumentException If the class cannot be autoloaded, or does not extend GraphQLControllerBase.
392 */
393 private static function assert_is_controller_subclass( string $controller_class_name ): void {
394 // Differentiate "class does not exist" (typo / stale autoloader) from
395 // "class exists but is not a subclass" — is_subclass_of() collapses
396 // both into false, which is confusing when debugging a bootstrap typo.
397 if ( ! class_exists( $controller_class_name ) ) {
398 throw new \InvalidArgumentException(
399 sprintf(
400 'GraphQL controller class "%s" does not exist or is not autoloadable. Check the spelling, or run `composer dump-autoload` if it was added since the last autoloader regeneration.',
401 esc_html( $controller_class_name )
402 )
403 );
404 }
405 if ( ! is_subclass_of( $controller_class_name, GraphQLControllerBase::class ) ) {
406 throw new \InvalidArgumentException(
407 sprintf(
408 'Class "%s" must extend %s.',
409 esc_html( $controller_class_name ),
410 esc_html( GraphQLControllerBase::class )
411 )
412 );
413 }
414 }
415 }
416