PATH:
home
/
thebhoeo
/
public_html
/
officepoint
/
wp-content
/
plugins
/
woocommerce
/
src
/
Internal
/
Abilities
/
REST
<?php /** * REST Ability Factory class file. */ declare( strict_types=1 ); namespace Automattic\WooCommerce\Internal\Abilities\REST; use Automattic\WooCommerce\Internal\MCP\Transport\WooCommerceRestTransport; defined( 'ABSPATH' ) || exit; /** * Factory class for creating abilities from REST controllers. * * Handles the conversion of WooCommerce REST API endpoints into WordPress abilities * that can be consumed by MCP or other systems. */ class RestAbilityFactory { /** * Metadata key that marks REST-derived abilities for deprecated WooCommerce MCP exposure. */ public const EXPOSE_IN_DEPRECATED_MCP_META_KEY = 'expose_in_deprecated_woocommerce_mcp'; /** * Register abilities for a REST controller based on configuration. * * @param array $config Controller configuration containing controller class and abilities array. */ public static function register_controller_abilities( array $config ): void { $controller_class = $config['controller']; if ( ! class_exists( $controller_class ) ) { return; } $controller = new $controller_class(); foreach ( $config['abilities'] as $ability_config ) { self::register_single_ability( $controller, $ability_config, $config['route'] ); } } /** * Register a single ability. * * @param object $controller REST controller instance. * @param array $ability_config Ability configuration array. * @param string $route REST route for this controller. */ private static function register_single_ability( $controller, array $ability_config, string $route ): void { // Only proceed if wp_register_ability function exists. if ( ! function_exists( 'wp_register_ability' ) ) { return; } try { $ability_args = array( 'label' => $ability_config['label'], 'description' => $ability_config['description'], 'category' => 'woocommerce-rest', 'input_schema' => self::get_schema_for_operation( $controller, $ability_config['operation'] ), 'output_schema' => self::get_output_schema( $controller, $ability_config['operation'] ), 'execute_callback' => function ( $input ) use ( $controller, $ability_config, $route ) { return self::execute_operation( $controller, $ability_config['operation'], $input, $route ); }, 'permission_callback' => function () use ( $controller, $ability_config ) { return self::check_permission( $controller, $ability_config['operation'] ); }, 'ability_class' => RestAbility::class, 'meta' => array( 'show_in_rest' => true, self::EXPOSE_IN_DEPRECATED_MCP_META_KEY => true, ), ); // Add readonly annotation for GET operations (list and get). if ( in_array( $ability_config['operation'], array( 'list', 'get' ), true ) ) { $ability_args['meta']['annotations'] = array( 'readonly' => true, ); } wp_register_ability( $ability_config['id'], $ability_args ); } catch ( \Throwable $e ) { // Log the error for debugging but don't break the registration of other abilities. if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( "Failed to register ability {$ability_config['id']}: " . $e->getMessage(), array( 'source' => 'woocommerce-rest-abilities' ) ); } } } /** * Get input schema based on operation type. * * @param object $controller REST controller instance. * @param string $operation Operation type (list, get, create, update, delete). * @return array Input schema array. */ private static function get_schema_for_operation( $controller, string $operation ): array { switch ( $operation ) { case 'list': // Use controller's collection parameters. if ( method_exists( $controller, 'get_collection_params' ) ) { return self::sanitize_args_to_schema( $controller->get_collection_params() ); } break; case 'create': // Use controller's creatable schema. if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) { $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::CREATABLE ); return self::sanitize_args_to_schema( $args ); } break; case 'update': // Use controller's editable schema + ID. if ( method_exists( $controller, 'get_endpoint_args_for_item_schema' ) ) { $args = $controller->get_endpoint_args_for_item_schema( \WP_REST_Server::EDITABLE ); $schema = self::sanitize_args_to_schema( $args ); // Add ID field for update operations. $schema['properties']['id'] = array( 'type' => 'integer', 'description' => __( 'Unique identifier for the resource', 'woocommerce' ), ); // Ensure ID is required. if ( ! isset( $schema['required'] ) ) { $schema['required'] = array(); } if ( ! in_array( 'id', $schema['required'], true ) ) { $schema['required'][] = 'id'; } return $schema; } break; case 'get': case 'delete': // Only need ID. return array( 'type' => 'object', 'properties' => array( 'id' => array( 'type' => 'integer', 'description' => __( 'Unique identifier for the resource', 'woocommerce' ), ), ), 'required' => array( 'id' ), ); } // Fallback. return array( 'type' => 'object' ); } /** * Valid JSON Schema types. * * @var array */ private static $valid_types = array( 'string', 'number', 'integer', 'boolean', 'object', 'array', 'null' ); /** * Subset of {@see self::$valid_types} considered scalar for output relaxation. * * When a field is declared as one of these in the source REST schema, output * validation widens it to {@see self::OUTPUT_SCALAR_UNION}. */ private const SCALAR_TYPES = array( 'string', 'integer', 'number', 'boolean' ); /** * Union we emit on output for any field originally declared as a single scalar. * * Covers three failure modes seen in the wild on WooCommerce REST responses: * 1. The field may legitimately be unset / null (e.g. `low_stock_amount`). * 2. The declared scalar disagrees with the scalar actually returned (e.g. * `shipping_class_id` declared `string`, returned as `int`). * 3. The declared scalar is returned as a non-scalar — most notably * `meta_data[].display_value`, declared `string` but routinely an array * when the underlying meta value is itself an array (variation * attributes, serialized custom data, etc.). * * The union is effectively "any JSON type." That makes the type constraint * a no-op for declared scalars, but it remains explicit (so validators that * require a `type` key are still satisfied) and contained to the MCP output * schema path. The alternative is per-controller schema fixes scattered * across legacy REST code. */ private const OUTPUT_SCALAR_UNION = array( 'string', 'integer', 'number', 'boolean', 'array', 'object', 'null' ); /** * Sanitize WordPress REST args to valid JSON Schema format. * * Converts WordPress REST API argument arrays to JSON Schema by: * - Removing PHP callbacks (sanitize_callback, validate_callback) * - Converting 'required' from boolean-per-field to array-of-names * - Removing WordPress-specific non-schema fields * - Preserving valid JSON Schema properties * - Converting invalid types (date-time, mixed, action) to valid JSON Schema * - Recursively sanitizing nested properties and items * - Deduplicating enum values * * @param array $args WordPress REST API arguments array. * @return array Valid JSON Schema object. */ private static function sanitize_args_to_schema( array $args ): array { $properties = array(); $required = array(); foreach ( $args as $key => $arg ) { $property = array(); // Copy valid JSON Schema fields, normalizing types. if ( isset( $arg['type'] ) ) { $property = self::normalize_type( $property, $arg['type'] ); } if ( isset( $arg['description'] ) ) { $property['description'] = $arg['description']; } if ( isset( $arg['default'] ) ) { $property['default'] = $arg['default']; } if ( isset( $arg['enum'] ) ) { $property['enum'] = self::dedupe_enum( $arg['enum'] ); } if ( isset( $arg['items'] ) ) { $property['items'] = self::sanitize_schema( $arg['items'] ); } if ( isset( $arg['minimum'] ) ) { $property['minimum'] = $arg['minimum']; } if ( isset( $arg['maximum'] ) ) { $property['maximum'] = $arg['maximum']; } if ( isset( $arg['format'] ) && ! isset( $property['format'] ) ) { $property['format'] = $arg['format']; } if ( isset( $arg['properties'] ) ) { $property['properties'] = self::sanitize_schema_properties( $arg['properties'] ); } // Convert readonly to readOnly (JSON Schema format). if ( isset( $arg['readonly'] ) && $arg['readonly'] ) { $property['readOnly'] = true; } // Collect required fields. if ( isset( $arg['required'] ) && true === $arg['required'] ) { $required[] = $key; } $properties[ $key ] = $property; } $schema = array( 'type' => 'object', 'properties' => $properties, ); if ( ! empty( $required ) ) { $schema['required'] = array_unique( $required ); } return $schema; } /** * Recursively sanitize a JSON Schema node. * * Fixes invalid types, deduplicates enums, and recurses into * nested properties and items. * * @param array $schema A JSON Schema node. * @return array Sanitized schema node. */ private static function sanitize_schema( array $schema ): array { if ( isset( $schema['type'] ) ) { $schema = self::normalize_type( $schema, $schema['type'] ); } if ( isset( $schema['enum'] ) ) { $schema['enum'] = self::dedupe_enum( $schema['enum'] ); } // Remove WordPress-style boolean 'required' — JSON Schema requires an array. if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) { unset( $schema['required'] ); } if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { // Collect required fields from nested boolean 'required' before sanitizing. $required = array(); foreach ( $schema['properties'] as $key => $property ) { if ( is_array( $property ) && isset( $property['required'] ) && true === $property['required'] ) { $required[] = $key; } } if ( ! empty( $required ) ) { $schema['required'] = isset( $schema['required'] ) && is_array( $schema['required'] ) ? array_values( array_unique( array_merge( $schema['required'], $required ) ) ) : $required; } $schema['properties'] = self::sanitize_schema_properties( $schema['properties'] ); } if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { if ( isset( $schema['items'][0] ) ) { // Tuple form: sanitize each positional entry. foreach ( $schema['items'] as $index => $entry ) { if ( is_array( $entry ) ) { $schema['items'][ $index ] = self::sanitize_schema( $entry ); } } } else { $schema['items'] = self::sanitize_schema( $schema['items'] ); } } return $schema; } /** * Sanitize a map of JSON Schema properties. * * @param array $properties Map of property name to schema. * @return array Sanitized properties map. */ private static function sanitize_schema_properties( array $properties ): array { foreach ( $properties as $key => $property ) { if ( is_array( $property ) ) { $properties[ $key ] = self::sanitize_schema( $property ); } } return $properties; } /** * Normalize a schema type value. * * Handles both string types ('string', 'date-time', etc.) and * array types (['string', 'null']) used for nullable fields. * * @param array $schema The schema node being built. * @param string|array $type The type value to normalize. * @return array Schema with normalized type (or type removed if all invalid). */ private static function normalize_type( array $schema, $type ): array { if ( is_string( $type ) ) { if ( 'date-time' === $type ) { $schema['type'] = 'string'; if ( ! isset( $schema['format'] ) ) { $schema['format'] = 'date-time'; } } elseif ( 'action' === $type ) { $schema['type'] = 'object'; } elseif ( in_array( $type, self::$valid_types, true ) ) { $schema['type'] = $type; } else { unset( $schema['type'] ); } return $schema; } if ( is_array( $type ) ) { $normalized = array(); foreach ( $type as $single ) { if ( ! is_string( $single ) ) { continue; } if ( 'date-time' === $single ) { $single = 'string'; if ( ! isset( $schema['format'] ) ) { $schema['format'] = 'date-time'; } } elseif ( 'action' === $single ) { $single = 'object'; } elseif ( ! in_array( $single, self::$valid_types, true ) ) { continue; } $normalized[] = $single; } $normalized = array_values( array_unique( $normalized ) ); if ( empty( $normalized ) ) { unset( $schema['type'] ); } elseif ( 1 === count( $normalized ) ) { $schema['type'] = $normalized[0]; } else { $schema['type'] = $normalized; } return $schema; } // Non-string, non-array type — remove it. unset( $schema['type'] ); return $schema; } /** * Remove duplicate enum values while preserving order. * * Uses JSON encoding for fingerprinting to correctly handle * mixed scalar types (1 vs '1'), nulls, and complex values (arrays). * * @param array $values Enum values. * @return array Deduplicated enum values. */ private static function dedupe_enum( array $values ): array { $seen = array(); $unique = array(); foreach ( $values as $value ) { $fingerprint = wp_json_encode( $value ); if ( isset( $seen[ $fingerprint ] ) ) { continue; } $seen[ $fingerprint ] = true; $unique[] = $value; } return $unique; } /** * Recursively relax an output schema so it accepts the shapes WooCommerce REST * controllers actually return. * * Used on output schemas only — input schemas keep their tighter constraints so * MCP clients still get useful hints when formatting tool calls. Must run AFTER * {@see self::sanitize_schema()}, which converts the `date-time` pseudo-type to * `type: "string"` + `format: "date-time"` — this method then strips the format. * * Relaxations: * * 1. `format: "date-time"` and `format: "uri"` are stripped. WooCommerce REST * date strings (e.g. `2025-11-24T16:31:43`) omit the timezone suffix RFC 3339 * requires, and `format: "uri"` fields routinely return empty strings. * 2. Any `type` whose declared members are all scalars and/or `null` is * widened to {@see self::OUTPUT_SCALAR_UNION} — every JSON type plus * `null`. Applies to single scalars (`string`, `integer`, `number`, * `boolean`) and to pre-existing unions like `[integer, null]`. Fields * that declare any compound type (`object`, `array`) are left alone. * This is a deliberate accuracy tradeoff: many WooCommerce REST * controllers declare types that disagree with what they actually return * (e.g. `shipping_class_id` declared `string` but returned as `int`; * `low_stock_amount` declared `[integer, null]` but returned as `""` * when unset; `meta_data[].display_value` declared `string` but * routinely an array for variation attributes and serialized custom * meta). The alternative is per-controller schema fixes across legacy * code. Skipped inside `anyOf` / `oneOf` / `allOf` branches: widening * every branch breaks the "exactly one" rule for `oneOf`, and for * `anyOf` / `allOf` the schema author was explicit about admissible * shapes. * * Recurses into `properties`, `items` (single schema and tuple form), * `additionalProperties`, and the `anyOf` / `oneOf` / `allOf` combiners. * * @param array $schema A JSON Schema node. * @param bool $apply_null_union Whether to apply the scalar-to-nullable widening at this node. * False when recursing into combiner branches. * @return array Relaxed schema node. */ private static function relax_output_schema_for_wc_quirks( array $schema, bool $apply_null_union = true ): array { if ( isset( $schema['format'] ) && in_array( $schema['format'], array( 'date-time', 'uri' ), true ) ) { unset( $schema['format'] ); } if ( $apply_null_union && isset( $schema['type'] ) && self::should_widen_to_output_union( $schema['type'] ) ) { $schema['type'] = self::OUTPUT_SCALAR_UNION; } if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) { foreach ( $schema['properties'] as $key => $property ) { if ( is_array( $property ) ) { $schema['properties'][ $key ] = self::relax_output_schema_for_wc_quirks( $property, $apply_null_union ); } } } if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) { if ( isset( $schema['items'][0] ) ) { // Tuple form: each numerically-indexed entry validates the array element at that position. foreach ( $schema['items'] as $index => $entry ) { if ( is_array( $entry ) ) { $schema['items'][ $index ] = self::relax_output_schema_for_wc_quirks( $entry, $apply_null_union ); } } } else { $schema['items'] = self::relax_output_schema_for_wc_quirks( $schema['items'], $apply_null_union ); } } if ( isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) { $schema['additionalProperties'] = self::relax_output_schema_for_wc_quirks( $schema['additionalProperties'], $apply_null_union ); } foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combiner ) { if ( isset( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) { foreach ( $schema[ $combiner ] as $index => $branch ) { if ( is_array( $branch ) ) { // Entering a combiner from anywhere disables null-union for the entire subtree. $schema[ $combiner ][ $index ] = self::relax_output_schema_for_wc_quirks( $branch, false ); } } } } return $schema; } /** * Decide whether an output-schema `type` should be widened to the full union. * * Widens when the declared type is either a single scalar (`integer`, `string`, etc.) * or an array union whose members are all scalars and/or `null`. Leaves the * declaration alone if any compound type (`object`, `array`) appears, since the * schema author was explicit about admitting a structured value. * * Handles the WC quirk where fields declared as `[integer, null]` * (e.g. `low_stock_amount`) are returned as empty strings when unset, which * neither member of the declared union admits. * * @param mixed $type Schema `type` value (string, array, or other). * @return bool True if the type should be replaced with {@see self::OUTPUT_SCALAR_UNION}. */ private static function should_widen_to_output_union( $type ): bool { if ( is_string( $type ) ) { return in_array( $type, self::SCALAR_TYPES, true ); } if ( ! is_array( $type ) || empty( $type ) ) { return false; } $widenable = array_merge( self::SCALAR_TYPES, array( 'null' ) ); foreach ( $type as $member ) { if ( ! is_string( $member ) || ! in_array( $member, $widenable, true ) ) { return false; } } return true; } /** * Get output schema for operation. * * @param object $controller REST controller instance. * @param string $operation Operation type. * @return array Output schema array. */ private static function get_output_schema( $controller, string $operation ): array { if ( method_exists( $controller, 'get_item_schema' ) ) { $schema = self::sanitize_schema( $controller->get_item_schema() ); $schema = self::relax_output_schema_for_wc_quirks( $schema ); if ( 'list' === $operation ) { // For list operations, return object wrapping array of items. // This ensures MCP compatibility while maintaining REST structure. return array( 'type' => 'object', 'properties' => array( 'data' => array( 'type' => 'array', 'items' => $schema, ), ), ); } elseif ( 'delete' === $operation ) { // For delete operations, return simple confirmation. return array( 'type' => 'object', 'properties' => array( 'deleted' => array( 'type' => 'boolean' ), 'previous' => $schema, ), ); } // For get, create, update operations. return $schema; } return array( 'type' => 'object' ); } /** * Execute the REST operation. * * @param object $controller REST controller instance. * @param string $operation Operation type. * @param array $input Input parameters. * @param string $route REST route for this controller. * @return mixed Operation result. */ private static function execute_operation( $controller, string $operation, array $input, string $route ) { $method = self::get_http_method_for_operation( $operation ); // Build final route - add ID for single item operations. $request_route = $route; if ( isset( $input['id'] ) && in_array( $operation, array( 'get', 'update', 'delete' ), true ) ) { $request_route .= '/' . intval( $input['id'] ); unset( $input['id'] ); } // Create REST request. $request = new \WP_REST_Request( $method, $request_route ); foreach ( $input as $key => $value ) { $request->set_param( $key, $value ); } // Dispatch through REST API for proper validation and permissions. $response = rest_do_request( $request ); if ( is_wp_error( $response ) ) { return $response; } $data = $response instanceof \WP_REST_Response ? $response->get_data() : $response; // For list operations, wrap in data object to match schema. if ( 'list' === $operation ) { return array( 'data' => $data ); } return $data; } /** * Get HTTP method for a given operation type. * * @param string $operation Operation type (list, get, create, update, delete). * @return string HTTP method (GET, POST, PUT, DELETE). */ private static function get_http_method_for_operation( string $operation ): string { $method_map = array( 'list' => 'GET', 'get' => 'GET', 'create' => 'POST', 'update' => 'PUT', 'delete' => 'DELETE', ); return $method_map[ $operation ] ?? 'GET'; } /** * Check permissions for MCP operations. * * @param object $controller REST controller instance. * @param string $operation Operation type. * @return bool Whether permission is granted. */ private static function check_permission( $controller, string $operation ): bool { // Get HTTP method for the operation. $method = self::get_http_method_for_operation( $operation ); /** * Filter to check REST ability permissions for HTTP method. * * @since 10.3.0 * @param bool $allowed Whether the operation is allowed. Default false. * @param string $method HTTP method (GET, POST, PUT, DELETE). * @param object $controller REST controller instance. */ return apply_filters( 'woocommerce_check_rest_ability_permissions_for_method', false, $method, $controller ); } }
[-] RestAbility.php
[edit]
[+]
..
[-] RestAbilityFactory.php
[edit]