PluginProbe ʕ •ᴥ•ʔ
WooCommerce / 10.9.3
WooCommerce v10.9.3
10.9.4 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 / Internal / Abilities / REST / RestAbilityFactory.php
woocommerce / src / Internal / Abilities / REST Last commit date
RestAbility.php 9 months ago RestAbilityFactory.php 4 weeks ago
RestAbilityFactory.php
675 lines
1 <?php
2 /**
3 * REST Ability Factory class file.
4 */
5
6 declare( strict_types=1 );
7
8 namespace Automattic\WooCommerce\Internal\Abilities\REST;
9
10 use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport;
11
12 defined( 'ABSPATH' ) || exit;
13
14 /**
15 * Factory class for creating abilities from REST controllers.
16 *
17 * Handles the conversion of WooCommerce REST API endpoints into WordPress abilities
18 * that can be consumed by MCP or other systems.
19 */
20 class RestAbilityFactory {
21
22 /**
23 * Metadata key that marks REST-derived abilities for deprecated WooCommerce MCP exposure.
24 */
25 public const EXPOSE_IN_DEPRECATED_MCP_META_KEY = 'expose_in_deprecated_woocommerce_mcp';
26
27 /**
28 * Register abilities for a REST controller based on configuration.
29 *
30 * @param array $config Controller configuration containing controller class and abilities array.
31 */
32 public static function register_controller_abilities( array $config ): void {
33 $controller_class = $config['controller'];
34
35 if ( ! class_exists( $controller_class ) ) {
36 return;
37 }
38
39 $controller = new $controller_class();
40
41 foreach ( $config['abilities'] as $ability_config ) {
42 self::register_single_ability( $controller, $ability_config, $config['route'] );
43 }
44 }
45
46 /**
47 * Register a single ability.
48 *
49 * @param object $controller REST controller instance.
50 * @param array $ability_config Ability configuration array.
51 * @param string $route REST route for this controller.
52 */
53 private static function register_single_ability( $controller, array $ability_config, string $route ): void {
54 // Only proceed if wp_register_ability function exists.
55 if ( ! function_exists( 'wp_register_ability' ) ) {
56 return;
57 }
58
59 try {
60 $ability_args = array(
61 'label' => $ability_config['label'],
62 'description' => $ability_config['description'],
63 'category' => 'woocommerce-rest',
64 'input_schema' => self::get_schema_for_operation( $controller, $ability_config['operation'] ),
65 'output_schema' => self::get_output_schema( $controller, $ability_config['operation'] ),
66 'execute_callback' => function ( $input ) use ( $controller, $ability_config, $route ) {
67 return self::execute_operation( $controller, $ability_config['operation'], $input, $route );
68 },
69 'permission_callback' => function () use ( $controller, $ability_config ) {
70 return self::check_permission( $controller, $ability_config['operation'] );
71 },
72 'ability_class' => RestAbility::class,
73 'meta' => array(
74 'show_in_rest' => true,
75 self::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true,
76 ),
77 );
78
79 // Add readonly annotation for GET operations (list and get).
80 if ( in_array( $ability_config['operation'], array( 'list', 'get' ), true ) ) {
81 $ability_args['meta']['annotations'] = array(
82 'readonly' => true,
83 );
84 }
85
86 wp_register_ability( $ability_config['id'], $ability_args );
87 } catch ( \Throwable $e ) {
88 // Log the error for debugging but don't break the registration of other abilities.
89 if ( function_exists( 'wc_get_logger' ) ) {
90 wc_get_logger()->error(
91 "Failed to register ability {$ability_config['id']}: " . $e->getMessage(),
92 array( 'source' => 'woocommerce-rest-abilities' )
93 );
94 }
95 }
96 }
97
98 /**
99 * Get input schema based on operation type.
100 *
101 * @param object $controller REST controller instance.
102 * @param string $operation Operation type (list, get, create, update, delete).
103 * @return array Input schema array.
104 */
105 private static function get_schema_for_operation( $controller, string $operation ): array {
106 switch ( $operation ) {
107 case 'list':
108 // Use controller's collection parameters.
109 if ( method_exists( $controller, 'get_collection_params' ) ) {
110 return self::sanitize_args_to_schema( $controller->get_collection_params() );
111 }
112 break;
113
114 case 'create':
115 // Use controller's creatable schema.
116 if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
117 $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE );
118 return self::sanitize_args_to_schema( $args );
119 }
120 break;
121
122 case 'update':
123 // Use controller's editable schema + ID.
124 if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) {
125 $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE );
126 $schema = self::sanitize_args_to_schema( $args );
127
128 // Add ID field for update operations.
129 $schema['properties']['id'] = array(
130 'type' => 'integer',
131 'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
132 );
133
134 // Ensure ID is required.
135 if ( ! isset( $schema['required'] ) ) {
136 $schema['required'] = array();
137 }
138 if ( ! in_array( 'id', $schema['required'], true ) ) {
139 $schema['required'][] = 'id';
140 }
141
142 return $schema;
143 }
144 break;
145
146 case 'get':
147 case 'delete':
148 // Only need ID.
149 return array(
150 'type' => 'object',
151 'properties' => array(
152 'id' => array(
153 'type' => 'integer',
154 'description' => __( 'Unique identifier for the resource', 'woocommerce' ),
155 ),
156 ),
157 'required' => array( 'id' ),
158 );
159 }
160
161 // Fallback.
162 return array( 'type' => 'object' );
163 }
164
165 /**
166 * Valid JSON Schema types.
167 *
168 * @var array
169 */
170 private static $valid_types = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' );
171
172 /**
173 * Subset of {@see self::$valid_types} considered scalar for output relaxation.
174 *
175 * When a field is declared as one of these in the source REST schema, output
176 * validation widens it to {@see self::OUTPUT_SCALAR_UNION}.
177 */
178 private const SCALAR_TYPES = array( 'string', 'integer', 'number', 'boolean' );
179
180 /**
181 * Union we emit on output for any field originally declared as a single scalar.
182 *
183 * Covers three failure modes seen in the wild on WooCommerce REST responses:
184 * 1. The field may legitimately be unset / null (e.g. `low_stock_amount`).
185 * 2. The declared scalar disagrees with the scalar actually returned (e.g.
186 * `shipping_class_id` declared `string`, returned as `int`).
187 * 3. The declared scalar is returned as a non-scalar — most notably
188 * `meta_data[].display_value`, declared `string` but routinely an array
189 * when the underlying meta value is itself an array (variation
190 * attributes, serialized custom data, etc.).
191 *
192 * The union is effectively "any JSON type." That makes the type constraint
193 * a no-op for declared scalars, but it remains explicit (so validators that
194 * require a `type` key are still satisfied) and contained to the MCP output
195 * schema path. The alternative is per-controller schema fixes scattered
196 * across legacy REST code.
197 */
198 private const OUTPUT_SCALAR_UNION = array( 'string', 'integer', 'number', 'boolean', 'array', 'object', 'null' );
199
200 /**
201 * Sanitize WordPress REST args to valid JSON Schema format.
202 *
203 * Converts WordPress REST API argument arrays to JSON Schema by:
204 * - Removing PHP callbacks (sanitize_callback, validate_callback)
205 * - Converting 'required' from boolean-per-field to array-of-names
206 * - Removing WordPress-specific non-schema fields
207 * - Preserving valid JSON Schema properties
208 * - Converting invalid types (date-time, mixed, action) to valid JSON Schema
209 * - Recursively sanitizing nested properties and items
210 * - Deduplicating enum values
211 *
212 * @param array $args WordPress REST API arguments array.
213 * @return array Valid JSON Schema object.
214 */
215 private static function sanitize_args_to_schema( array $args ): array {
216 $properties = array();
217 $required = array();
218
219 foreach ( $args as $key => $arg ) {
220 $property = array();
221
222 // Copy valid JSON Schema fields, normalizing types.
223 if ( isset( $arg['type'] ) ) {
224 $property = self::normalize_type( $property, $arg['type'] );
225 }
226 if ( isset( $arg['description'] ) ) {
227 $property['description'] = $arg['description'];
228 }
229 if ( isset( $arg['default'] ) ) {
230 $property['default'] = $arg['default'];
231 }
232 if ( isset( $arg['enum'] ) ) {
233 $property['enum'] = self::dedupe_enum( $arg['enum'] );
234 }
235 if ( isset( $arg['items'] ) ) {
236 $property['items'] = self::sanitize_schema( $arg['items'] );
237 }
238 if ( isset( $arg['minimum'] ) ) {
239 $property['minimum'] = $arg['minimum'];
240 }
241 if ( isset( $arg['maximum'] ) ) {
242 $property['maximum'] = $arg['maximum'];
243 }
244 if ( isset( $arg['format'] ) && ! isset( $property['format'] ) ) {
245 $property['format'] = $arg['format'];
246 }
247 if ( isset( $arg['properties'] ) ) {
248 $property['properties'] = self::sanitize_schema_properties( $arg['properties'] );
249 }
250
251 // Convert readonly to readOnly (JSON Schema format).
252 if ( isset( $arg['readonly'] ) && $arg['readonly'] ) {
253 $property['readOnly'] = true;
254 }
255
256 // Collect required fields.
257 if ( isset( $arg['required'] ) && true === $arg['required'] ) {
258 $required[] = $key;
259 }
260
261 $properties[ $key ] = $property;
262 }
263
264 $schema = array(
265 'type' => 'object',
266 'properties' => $properties,
267 );
268
269 if ( ! empty( $required ) ) {
270 $schema['required'] = array_unique( $required );
271 }
272
273 return $schema;
274 }
275
276 /**
277 * Recursively sanitize a JSON Schema node.
278 *
279 * Fixes invalid types, deduplicates enums, and recurses into
280 * nested properties and items.
281 *
282 * @param array $schema A JSON Schema node.
283 * @return array Sanitized schema node.
284 */
285 private static function sanitize_schema( array $schema ): array {
286 if ( isset( $schema['type'] ) ) {
287 $schema = self::normalize_type( $schema, $schema['type'] );
288 }
289
290 if ( isset( $schema['enum'] ) ) {
291 $schema['enum'] = self::dedupe_enum( $schema['enum'] );
292 }
293
294 // Remove WordPress-style boolean 'required' — JSON Schema requires an array.
295 if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
296 unset( $schema['required'] );
297 }
298
299 if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
300 // Collect required fields from nested boolean 'required' before sanitizing.
301 $required = array();
302 foreach ( $schema['properties'] as $key => $property ) {
303 if ( is_array( $property ) && isset( $property['required'] ) && true === $property['required'] ) {
304 $required[] = $key;
305 }
306 }
307 if ( ! empty( $required ) ) {
308 $schema['required'] = isset( $schema['required'] ) && is_array( $schema['required'] )
309 ? array_values( array_unique( array_merge( $schema['required'], $required ) ) )
310 : $required;
311 }
312
313 $schema['properties'] = self::sanitize_schema_properties( $schema['properties'] );
314 }
315
316 if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
317 if ( isset( $schema['items'][0] ) ) {
318 // Tuple form: sanitize each positional entry.
319 foreach ( $schema['items'] as $index => $entry ) {
320 if ( is_array( $entry ) ) {
321 $schema['items'][ $index ] = self::sanitize_schema( $entry );
322 }
323 }
324 } else {
325 $schema['items'] = self::sanitize_schema( $schema['items'] );
326 }
327 }
328
329 return $schema;
330 }
331
332 /**
333 * Sanitize a map of JSON Schema properties.
334 *
335 * @param array $properties Map of property name to schema.
336 * @return array Sanitized properties map.
337 */
338 private static function sanitize_schema_properties( array $properties ): array {
339 foreach ( $properties as $key => $property ) {
340 if ( is_array( $property ) ) {
341 $properties[ $key ] = self::sanitize_schema( $property );
342 }
343 }
344 return $properties;
345 }
346
347 /**
348 * Normalize a schema type value.
349 *
350 * Handles both string types ('string', 'date-time', etc.) and
351 * array types (['string', 'null']) used for nullable fields.
352 *
353 * @param array $schema The schema node being built.
354 * @param string|array $type The type value to normalize.
355 * @return array Schema with normalized type (or type removed if all invalid).
356 */
357 private static function normalize_type( array $schema, $type ): array {
358 if ( is_string( $type ) ) {
359 if ( 'date-time' === $type ) {
360 $schema['type'] = 'string';
361 if ( ! isset( $schema['format'] ) ) {
362 $schema['format'] = 'date-time';
363 }
364 } elseif ( 'action' === $type ) {
365 $schema['type'] = 'object';
366 } elseif ( in_array( $type, self::$valid_types, true ) ) {
367 $schema['type'] = $type;
368 } else {
369 unset( $schema['type'] );
370 }
371 return $schema;
372 }
373
374 if ( is_array( $type ) ) {
375 $normalized = array();
376 foreach ( $type as $single ) {
377 if ( ! is_string( $single ) ) {
378 continue;
379 }
380 if ( 'date-time' === $single ) {
381 $single = 'string';
382 if ( ! isset( $schema['format'] ) ) {
383 $schema['format'] = 'date-time';
384 }
385 } elseif ( 'action' === $single ) {
386 $single = 'object';
387 } elseif ( ! in_array( $single, self::$valid_types, true ) ) {
388 continue;
389 }
390 $normalized[] = $single;
391 }
392 $normalized = array_values( array_unique( $normalized ) );
393 if ( empty( $normalized ) ) {
394 unset( $schema['type'] );
395 } elseif ( 1 === count( $normalized ) ) {
396 $schema['type'] = $normalized[0];
397 } else {
398 $schema['type'] = $normalized;
399 }
400 return $schema;
401 }
402
403 // Non-string, non-array type — remove it.
404 unset( $schema['type'] );
405 return $schema;
406 }
407
408 /**
409 * Remove duplicate enum values while preserving order.
410 *
411 * Uses JSON encoding for fingerprinting to correctly handle
412 * mixed scalar types (1 vs '1'), nulls, and complex values (arrays).
413 *
414 * @param array $values Enum values.
415 * @return array Deduplicated enum values.
416 */
417 private static function dedupe_enum( array $values ): array {
418 $seen = array();
419 $unique = array();
420 foreach ( $values as $value ) {
421 $fingerprint = wp_json_encode( $value );
422 if ( isset( $seen[ $fingerprint ] ) ) {
423 continue;
424 }
425 $seen[ $fingerprint ] = true;
426 $unique[] = $value;
427 }
428 return $unique;
429 }
430
431 /**
432 * Recursively relax an output schema so it accepts the shapes WooCommerce REST
433 * controllers actually return.
434 *
435 * Used on output schemas only — input schemas keep their tighter constraints so
436 * MCP clients still get useful hints when formatting tool calls. Must run AFTER
437 * {@see self::sanitize_schema()}, which converts the `date-time` pseudo-type to
438 * `type: "string"` + `format: "date-time"` — this method then strips the format.
439 *
440 * Relaxations:
441 *
442 * 1. `format: "date-time"` and `format: "uri"` are stripped. WooCommerce REST
443 * date strings (e.g. `2025-11-24T16:31:43`) omit the timezone suffix RFC 3339
444 * requires, and `format: "uri"` fields routinely return empty strings.
445 * 2. Any `type` whose declared members are all scalars and/or `null` is
446 * widened to {@see self::OUTPUT_SCALAR_UNION} — every JSON type plus
447 * `null`. Applies to single scalars (`string`, `integer`, `number`,
448 * `boolean`) and to pre-existing unions like `[integer, null]`. Fields
449 * that declare any compound type (`object`, `array`) are left alone.
450 * This is a deliberate accuracy tradeoff: many WooCommerce REST
451 * controllers declare types that disagree with what they actually return
452 * (e.g. `shipping_class_id` declared `string` but returned as `int`;
453 * `low_stock_amount` declared `[integer, null]` but returned as `""`
454 * when unset; `meta_data[].display_value` declared `string` but
455 * routinely an array for variation attributes and serialized custom
456 * meta). The alternative is per-controller schema fixes across legacy
457 * code. Skipped inside `anyOf` / `oneOf` / `allOf` branches: widening
458 * every branch breaks the "exactly one" rule for `oneOf`, and for
459 * `anyOf` / `allOf` the schema author was explicit about admissible
460 * shapes.
461 *
462 * Recurses into `properties`, `items` (single schema and tuple form),
463 * `additionalProperties`, and the `anyOf` / `oneOf` / `allOf` combiners.
464 *
465 * @param array $schema A JSON Schema node.
466 * @param bool $apply_null_union Whether to apply the scalar-to-nullable widening at this node.
467 * False when recursing into combiner branches.
468 * @return array Relaxed schema node.
469 */
470 private static function relax_output_schema_for_wc_quirks( array $schema, bool $apply_null_union = true ): array {
471 if ( isset( $schema['format'] ) && in_array( $schema['format'], array( 'date-time', 'uri' ), true ) ) {
472 unset( $schema['format'] );
473 }
474
475 if ( $apply_null_union && isset( $schema['type'] ) && self::should_widen_to_output_union( $schema['type'] ) ) {
476 $schema['type'] = self::OUTPUT_SCALAR_UNION;
477 }
478
479 if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
480 foreach ( $schema['properties'] as $key => $property ) {
481 if ( is_array( $property ) ) {
482 $schema['properties'][ $key ] = self::relax_output_schema_for_wc_quirks( $property, $apply_null_union );
483 }
484 }
485 }
486
487 if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
488 if ( isset( $schema['items'][0] ) ) {
489 // Tuple form: each numerically-indexed entry validates the array element at that position.
490 foreach ( $schema['items'] as $index => $entry ) {
491 if ( is_array( $entry ) ) {
492 $schema['items'][ $index ] = self::relax_output_schema_for_wc_quirks( $entry, $apply_null_union );
493 }
494 }
495 } else {
496 $schema['items'] = self::relax_output_schema_for_wc_quirks( $schema['items'], $apply_null_union );
497 }
498 }
499
500 if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
501 $schema['additionalProperties'] = self::relax_output_schema_for_wc_quirks( $schema['additionalProperties'], $apply_null_union );
502 }
503
504 foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combiner ) {
505 if ( isset( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) {
506 foreach ( $schema[ $combiner ] as $index => $branch ) {
507 if ( is_array( $branch ) ) {
508 // Entering a combiner from anywhere disables null-union for the entire subtree.
509 $schema[ $combiner ][ $index ] = self::relax_output_schema_for_wc_quirks( $branch, false );
510 }
511 }
512 }
513 }
514
515 return $schema;
516 }
517
518 /**
519 * Decide whether an output-schema `type` should be widened to the full union.
520 *
521 * Widens when the declared type is either a single scalar (`integer`, `string`, etc.)
522 * or an array union whose members are all scalars and/or `null`. Leaves the
523 * declaration alone if any compound type (`object`, `array`) appears, since the
524 * schema author was explicit about admitting a structured value.
525 *
526 * Handles the WC quirk where fields declared as `[integer, null]`
527 * (e.g. `low_stock_amount`) are returned as empty strings when unset, which
528 * neither member of the declared union admits.
529 *
530 * @param mixed $type Schema `type` value (string, array, or other).
531 * @return bool True if the type should be replaced with {@see self::OUTPUT_SCALAR_UNION}.
532 */
533 private static function should_widen_to_output_union( $type ): bool {
534 if ( is_string( $type ) ) {
535 return in_array( $type, self::SCALAR_TYPES, true );
536 }
537
538 if ( ! is_array( $type ) || empty( $type ) ) {
539 return false;
540 }
541
542 $widenable = array_merge( self::SCALAR_TYPES, array( 'null' ) );
543 foreach ( $type as $member ) {
544 if ( ! is_string( $member ) || ! in_array( $member, $widenable, true ) ) {
545 return false;
546 }
547 }
548
549 return true;
550 }
551
552 /**
553 * Get output schema for operation.
554 *
555 * @param object $controller REST controller instance.
556 * @param string $operation Operation type.
557 * @return array Output schema array.
558 */
559 private static function get_output_schema( $controller, string $operation ): array {
560 if ( method_exists( $controller, 'get_item_schema' ) ) {
561 $schema = self::sanitize_schema( $controller->get_item_schema() );
562 $schema = self::relax_output_schema_for_wc_quirks( $schema );
563
564 if ( 'list' === $operation ) {
565 // For list operations, return object wrapping array of items.
566 // This ensures MCP compatibility while maintaining REST structure.
567 return array(
568 'type' => 'object',
569 'properties' => array(
570 'data' => array(
571 'type' => 'array',
572 'items' => $schema,
573 ),
574 ),
575 );
576 } elseif ( 'delete' === $operation ) {
577 // For delete operations, return simple confirmation.
578 return array(
579 'type' => 'object',
580 'properties' => array(
581 'deleted' => array( 'type' => 'boolean' ),
582 'previous' => $schema,
583 ),
584 );
585 }
586
587 // For get, create, update operations.
588 return $schema;
589 }
590
591 return array( 'type' => 'object' );
592 }
593
594 /**
595 * Execute the REST operation.
596 *
597 * @param object $controller REST controller instance.
598 * @param string $operation Operation type.
599 * @param array $input Input parameters.
600 * @param string $route REST route for this controller.
601 * @return mixed Operation result.
602 */
603 private static function execute_operation( $controller, string $operation, array $input, string $route ) {
604 $method = self::get_http_method_for_operation( $operation );
605
606 // Build final route - add ID for single item operations.
607 $request_route = $route;
608 if ( isset( $input['id'] ) && in_array( $operation, array( 'get', 'update', 'delete' ), true ) ) {
609 $request_route .= '/' . intval( $input['id'] );
610 unset( $input['id'] );
611 }
612
613 // Create REST request.
614 $request = new \WP_REST_Request( $method, $request_route );
615 foreach ( $input as $key => $value ) {
616 $request->set_param( $key, $value );
617 }
618
619 // Dispatch through REST API for proper validation and permissions.
620 $response = rest_do_request( $request );
621
622 if ( is_wp_error( $response ) ) {
623 return $response;
624 }
625
626 $data = $response instanceof \WP_REST_Response ? $response->get_data() : $response;
627
628 // For list operations, wrap in data object to match schema.
629 if ( 'list' === $operation ) {
630 return array( 'data' => $data );
631 }
632
633 return $data;
634 }
635
636 /**
637 * Get HTTP method for a given operation type.
638 *
639 * @param string $operation Operation type (list, get, create, update, delete).
640 * @return string HTTP method (GET, POST, PUT, DELETE).
641 */
642 private static function get_http_method_for_operation( string $operation ): string {
643 $method_map = array(
644 'list' => 'GET',
645 'get' => 'GET',
646 'create' => 'POST',
647 'update' => 'PUT',
648 'delete' => 'DELETE',
649 );
650 return $method_map[ $operation ] ?? 'GET';
651 }
652
653 /**
654 * Check permissions for MCP operations.
655 *
656 * @param object $controller REST controller instance.
657 * @param string $operation Operation type.
658 * @return bool Whether permission is granted.
659 */
660 private static function check_permission( $controller, string $operation ): bool {
661 // Get HTTP method for the operation.
662 $method = self::get_http_method_for_operation( $operation );
663
664 /**
665 * Filter to check REST ability permissions for HTTP method.
666 *
667 * @since 10.3.0
668 * @param bool $allowed Whether the operation is allowed. Default false.
669 * @param string $method HTTP method (GET, POST, PUT, DELETE).
670 * @param object $controller REST controller instance.
671 */
672 return apply_filters( 'woocommerce_check_rest_ability_permissions_for_method', false, $method, $controller );
673 }
674 }
675