Automattic\WooCommerce\Internal\Abilities\REST
RestAbilityFactory{} │ WC 1.0
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.
Hooks from the class
Usage
$RestAbilityFactory = new RestAbilityFactory(); // use class methods
Methods
- public static register_controller_abilities( array $config )
- private static check_permission( $controller, string $operation )
- private static dedupe_enum( array $values )
- private static execute_operation( $controller, string $operation, array $input, string $route )
- private static get_http_method_for_operation( string $operation )
- private static get_output_schema( $controller, string $operation )
- private static get_schema_for_operation( $controller, string $operation )
- private static normalize_type( array $schema, $type )
- private static register_single_ability( $controller, array $ability_config, string $route )
- private static relax_output_schema_for_wc_quirks( array $schema, bool $apply_null_union = true )
- private static sanitize_args_to_schema( array $args )
- private static sanitize_schema( array $schema )
- private static sanitize_schema_properties( array $properties )
- private static should_widen_to_output_union( $type )
RestAbilityFactory{} RestAbilityFactory{} code WC 10.9.4
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 );
}
}