From d03adebe564ccf55bb603f480b3f6c7373db50a8 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Wed, 4 Mar 2026 15:46:37 +0000 Subject: [PATCH 01/16] Set up abilities API abstraction layers --- woocommerce/Abilities/AbilitiesHandler.php | 88 ++++++++++++ .../Abilities/AbstractAbilitiesProvider.php | 72 ++++++++++ .../Contracts/AbilitiesProviderContract.php | 19 +++ .../Contracts/HasAbilitiesContract.php | 23 ++++ .../Contracts/MakesAbilityContract.php | 25 ++++ woocommerce/Abilities/DataObjects/Ability.php | 127 ++++++++++++++++++ .../DataObjects/AbilityAnnotations.php | 48 +++++++ .../Abilities/DataObjects/AbilityCategory.php | 59 ++++++++ .../Abilities/DataObjects/RestApiConfig.php | 45 +++++++ woocommerce/class-sv-wc-plugin.php | 37 +++++ 10 files changed, 543 insertions(+) create mode 100644 woocommerce/Abilities/AbilitiesHandler.php create mode 100644 woocommerce/Abilities/AbstractAbilitiesProvider.php create mode 100644 woocommerce/Abilities/Contracts/AbilitiesProviderContract.php create mode 100644 woocommerce/Abilities/Contracts/HasAbilitiesContract.php create mode 100644 woocommerce/Abilities/Contracts/MakesAbilityContract.php create mode 100644 woocommerce/Abilities/DataObjects/Ability.php create mode 100644 woocommerce/Abilities/DataObjects/AbilityAnnotations.php create mode 100644 woocommerce/Abilities/DataObjects/AbilityCategory.php create mode 100644 woocommerce/Abilities/DataObjects/RestApiConfig.php diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php new file mode 100644 index 000000000..b059991fc --- /dev/null +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -0,0 +1,88 @@ +abilitiesProvider = $abilitiesProvider; + + $this->addHooks(); + } + + /** + * Hooks into WordPress Abilities API initialization actions. + * + * @since 6.1.0 + * + * @return void + */ + protected function addHooks() : void + { + add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); + add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); + } + + /** + * Handles the categories init hook. + * + * Lazily collects abilities from the plugin, then registers categories with WordPress. + * + * @internal + * + * @since 6.1.0 + * + * @return void + */ + public function handleCategoriesInit() : void + { + if (! function_exists('wp_register_ability_category')) { + return; + } + + foreach ($this->abilitiesProvider->getCategories() as $category) { + wp_register_ability_category($category->slug, $category->toArray()); + } + } + + /** + * Handles the abilities init hook by registering the abilities with WordPress. + * + * @internal + * + * @since 6.1.0 + * + * @return void + */ + public function handleAbilitiesInit() : void + { + if (! function_exists('wp_register_ability')) { + return; + } + + foreach ($this->abilitiesProvider->getAbilities() as $ability) { + wp_register_ability($ability->getName(), $ability->toArray()); + } + } +} diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php new file mode 100644 index 000000000..17dbb95c6 --- /dev/null +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -0,0 +1,72 @@ +plugin = $plugin; + } + + /** + * @return AbilityCategory[] + */ + public function getCategories() : array + { + return []; + } + + /** + * @return Ability[] + */ + public function getAbilities() : array + { + $abilities = []; + + foreach ($this->abilities as $className) { + if (! is_string($className) || ! in_array(MakesAbilityContract::class, class_implements($className) ?: [], true)) { + _doing_it_wrong( + __METHOD__, + sprintf('Ability class "%s" must implement %s.', $className, MakesAbilityContract::class), + '6.1.0' + ); + continue; + } + + $abilities[] = (new $className)->makeAbility(); + } + + return $abilities; + } +} diff --git a/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php new file mode 100644 index 000000000..d090fcae7 --- /dev/null +++ b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php @@ -0,0 +1,19 @@ + JSON Schema describing the expected input */ + public array $inputSchema; + + /** @var array JSON Schema describing the output */ + public array $outputSchema; + + /** @var ?AbilityAnnotations behavioral annotations (readonly, destructive, idempotent) */ + public ?AbilityAnnotations $annotations; + + /** @var bool whether this ability should be exposed in the REST API */ + public bool $showInRest; + + /** @var ?RestApiConfig future REST API configuration (not acted on in this PoC) */ + public ?RestApiConfig $restApiConfig; + + /** + * @param string $name unique ability name + * @param string $label human-readable label + * @param string $description description of what the ability does + * @param string $category category slug this ability belongs to + * @param callable $executeCallback callback that executes the ability + * @param callable $permissionCallback callback that checks permissions + * @param array $inputSchema JSON Schema for the expected input + * @param array $outputSchema JSON Schema for the output + * @param ?AbilityAnnotations $annotations behavioral annotations + * @param bool $showInRest whether to expose in the REST API + * @param ?RestApiConfig $restApiConfig future REST API configuration + */ + public function __construct( + string $name, + string $label, + string $description, + string $category, + callable $executeCallback, + callable $permissionCallback, + array $inputSchema = [], + array $outputSchema = [], + ?AbilityAnnotations $annotations = null, + bool $showInRest = true, + ?RestApiConfig $restApiConfig = null + ) + { + $this->name = $name; + $this->label = $label; + $this->description = $description; + $this->category = $category; + $this->executeCallback = $executeCallback; + $this->permissionCallback = $permissionCallback; + $this->inputSchema = $inputSchema; + $this->outputSchema = $outputSchema; + $this->annotations = $annotations; + $this->showInRest = $showInRest; + $this->restApiConfig = $restApiConfig; + } + + /** + * Gets the ability name. + * + * @since 6.1.0 + * + * @return string + */ + public function getName() : string + { + return $this->name; + } + + /** + * Maps properties to the structure expected by wp_register_ability(), + * including the nested meta.annotations and meta.show_in_rest format. + * + * @since 6.1.0 + */ + public function toArray() : array + { + $args = [ + 'label' => $this->label, + 'description' => $this->description, + 'category' => $this->category, + 'execute_callback' => $this->executeCallback, + 'permission_callback' => $this->permissionCallback, + 'input_schema' => $this->inputSchema, + 'output_schema' => $this->outputSchema, + 'meta' => [ + 'show_in_rest' => $this->showInRest, + ], + ]; + + if ($this->annotations) { + $args['meta']['annotations'] = $this->annotations->toArray(); + } + + return $args; + } +} diff --git a/woocommerce/Abilities/DataObjects/AbilityAnnotations.php b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php new file mode 100644 index 000000000..5b63d23b2 --- /dev/null +++ b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php @@ -0,0 +1,48 @@ +readonly = $readonly; + $this->destructive = $destructive; + $this->idempotent = $idempotent; + } + + /** + * Returns the array format expected by WordPress. + * + * @since 6.1.0 + */ + public function toArray() : array + { + return [ + 'readonly' => $this->readonly, + 'destructive' => $this->destructive, + 'idempotent' => $this->idempotent, + ]; + } +} diff --git a/woocommerce/Abilities/DataObjects/AbilityCategory.php b/woocommerce/Abilities/DataObjects/AbilityCategory.php new file mode 100644 index 000000000..0c6f0ec76 --- /dev/null +++ b/woocommerce/Abilities/DataObjects/AbilityCategory.php @@ -0,0 +1,59 @@ + optional additional metadata */ + public array $meta = []; + + public function __construct( + string $slug, + string $label, + string $description, + array $meta = [] + ) + { + $this->slug = $slug; + $this->label = $label; + $this->description = $description; + $this->meta = $meta; + } + + /** + * {@inheritDoc} + * + * Returns the format expected by wp_register_ability_category(), excluding slug + * since that is passed as a positional argument. + * + * @since 6.1.0 + */ + public function toArray() : array + { + return [ + 'label' => $this->label, + 'description' => $this->description, + 'meta' => $this->meta, + ]; + } +} diff --git a/woocommerce/Abilities/DataObjects/RestApiConfig.php b/woocommerce/Abilities/DataObjects/RestApiConfig.php new file mode 100644 index 000000000..9b81d29c8 --- /dev/null +++ b/woocommerce/Abilities/DataObjects/RestApiConfig.php @@ -0,0 +1,45 @@ +addRestApiEndpoint = $addRestApiEndpoint; + $this->apiRoute = $apiRoute; + $this->controllerOverride = $controllerOverride; + $this->inputAdapter = $inputAdapter; + $this->outputAdapter = $outputAdapter; + } +} diff --git a/woocommerce/class-sv-wc-plugin.php b/woocommerce/class-sv-wc-plugin.php index 0b0400ded..902f610ad 100644 --- a/woocommerce/class-sv-wc-plugin.php +++ b/woocommerce/class-sv-wc-plugin.php @@ -25,6 +25,7 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_0_1; use Automattic\WooCommerce\Utilities\FeaturesUtil; +use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities\Contracts\HasAbilitiesContract; use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Handlers\Country_Helper; use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Payment_Gateway\PaymentFormContextChecker; use stdClass; @@ -101,6 +102,9 @@ abstract class SV_WC_Plugin { /** @var Blocks\Blocks_Handler blocks handler instance */ protected Blocks\Blocks_Handler $blocks_handler; + /** @var ?Abilities\AbilitiesHandler abilities handler instance */ + protected ?Abilities\AbilitiesHandler $abilities_handler = null; + /** @var Admin\Setup_Wizard handler instance */ protected $setup_wizard_handler; @@ -179,6 +183,9 @@ public function __construct( string $id, string $version, array $args = [] ) { // build the blocks handler instance $this->init_blocks_handler(); + // build the abilities handler instance + $this->init_abilities_handler(); + // add the action & filter hooks $this->add_hooks(); } @@ -311,6 +318,23 @@ protected function init_blocks_handler() : void { } + /** + * Builds the abilities handler instance. + * + * Hooks into the WordPress Abilities API (WP 6.9+) to register plugin abilities. + * + * @since 6.1.0 + * + * @return void + */ + protected function init_abilities_handler() : void { + + if ( $this instanceof HasAbilitiesContract) { + $this->abilities_handler = new Abilities\AbilitiesHandler($this->getAbilitiesProvider()); + } + } + + /** * Builds the Setup Wizard handler instance. * @@ -1021,6 +1045,19 @@ public function get_blocks_handler() : Blocks\Blocks_Handler { } + /** + * Gets the abilities handler instance. + * + * @since 6.1.0 + * + * @return ?Abilities\AbilitiesHandler + */ + public function get_abilities_handler() : ?Abilities\AbilitiesHandler { + + return $this->abilities_handler; + } + + /** * Gets the Setup Wizard handler instance. * From 5ac747c341c55f8a8ec8120d00311d0d314764b0 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 5 Mar 2026 10:41:08 +0000 Subject: [PATCH 02/16] Remove unnecessary properties --- .../Abilities/DataObjects/RestApiConfig.php | 22 +------------------ 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/woocommerce/Abilities/DataObjects/RestApiConfig.php b/woocommerce/Abilities/DataObjects/RestApiConfig.php index 9b81d29c8..0e5838e4b 100644 --- a/woocommerce/Abilities/DataObjects/RestApiConfig.php +++ b/woocommerce/Abilities/DataObjects/RestApiConfig.php @@ -13,33 +13,13 @@ */ class RestApiConfig { - /** @var bool whether to add a REST API endpoint for this ability */ - public bool $addRestApiEndpoint; - /** @var string the API route path */ public string $apiRoute; - /** @var ?string fully qualified class name for a custom controller */ - public ?string $controllerOverride; - - /** @var ?string fully qualified class name for an input adapter */ - public ?string $inputAdapter; - - /** @var ?string fully qualified class name for an output adapter */ - public ?string $outputAdapter; - public function __construct( - bool $addRestApiEndpoint = false, - string $apiRoute = '', - ?string $controllerOverride = null, - ?string $inputAdapter = null, - ?string $outputAdapter = null + string $apiRoute ) { - $this->addRestApiEndpoint = $addRestApiEndpoint; $this->apiRoute = $apiRoute; - $this->controllerOverride = $controllerOverride; - $this->inputAdapter = $inputAdapter; - $this->outputAdapter = $outputAdapter; } } From ebc00fc400c8954030847eecfe894848874aacf8 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 5 Mar 2026 12:27:51 +0000 Subject: [PATCH 03/16] Implement rest as an example --- woocommerce/Abilities/AbilitiesHandler.php | 23 ++ .../Abilities/DataObjects/RestApiConfig.php | 19 +- .../Abilities/REST/AbilityRestController.php | 228 ++++++++++++++++++ 3 files changed, 265 insertions(+), 5 deletions(-) create mode 100644 woocommerce/Abilities/REST/AbilityRestController.php diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index b059991fc..971f6f8af 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -3,6 +3,7 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities; use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities\Contracts\AbilitiesProviderContract; +use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities\REST\AbilityRestController; /** * Central handler for the WordPress Abilities API. @@ -42,6 +43,7 @@ protected function addHooks() : void { add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); + add_action('rest_api_init', [$this, 'handleRestApiInit']); } /** @@ -85,4 +87,25 @@ public function handleAbilitiesInit() : void wp_register_ability($ability->getName(), $ability->toArray()); } } + + /** + * Handles the REST API init hook by auto-registering endpoints for abilities that have a RestApiConfig. + * + * @internal + * + * @since 6.1.0 + * + * @return void + */ + public function handleRestApiInit() : void + { + foreach ($this->abilitiesProvider->getAbilities() as $ability) { + if ($ability->restApiConfig === null) { + continue; + } + + $controller = new AbilityRestController($ability); + $controller->register_routes(); + } + } } diff --git a/woocommerce/Abilities/DataObjects/RestApiConfig.php b/woocommerce/Abilities/DataObjects/RestApiConfig.php index 0e5838e4b..a5ba850d5 100644 --- a/woocommerce/Abilities/DataObjects/RestApiConfig.php +++ b/woocommerce/Abilities/DataObjects/RestApiConfig.php @@ -3,23 +3,32 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities\DataObjects; /** - * Data object for future REST API endpoint configuration. + * Data object for REST API endpoint configuration. * - * This is structural scaffolding for a future iteration where abilities can - * optionally expose dedicated REST API endpoints beyond the default WP Abilities API. - * Not acted upon by the handler in this PoC. + * When an ability provides a RestApiConfig, the framework auto-registers + * a WP_REST_Controller-based endpoint for it. * * @since 6.1.0 */ class RestApiConfig { + /** @var string REST API namespace (e.g. "wc/v5") */ + public string $namespace; + /** @var string the API route path */ public string $apiRoute; + /** @var string HTTP method */ + public string $method; + public function __construct( - string $apiRoute + string $namespace, + string $apiRoute, + string $method ) { + $this->namespace = $namespace; $this->apiRoute = $apiRoute; + $this->method = $method; } } diff --git a/woocommerce/Abilities/REST/AbilityRestController.php b/woocommerce/Abilities/REST/AbilityRestController.php new file mode 100644 index 000000000..6a75ab1bd --- /dev/null +++ b/woocommerce/Abilities/REST/AbilityRestController.php @@ -0,0 +1,228 @@ +ability = $ability; + $this->namespace = $ability->restApiConfig->namespace; + $this->rest_base = $ability->restApiConfig->apiRoute; + } + + protected function getWpAbility() : ?\WP_Ability + { + if (! isset($this->wpAbility)) { + $this->wpAbility = wp_get_ability($this->ability->name); + } + + return $this->wpAbility; + } + + /** + * Registers the REST route derived from the ability's RestApiConfig. + * + * @since 6.1.0 + */ + public function register_routes() : void + { + register_rest_route( + $this->namespace, + '/' . $this->rest_base, + [ + [ + 'methods' => $this->ability->restApiConfig->method, + 'callback' => [$this, 'handle_request'], + 'permission_callback' => [$this, 'check_permission'], + 'args' => $this->build_route_args(), + ], + 'schema' => [$this, 'get_public_item_schema'], + ] + ); + } + + /** + * Delegates permission check to the ability's permissionCallback. + * + * @since 6.1.0 + * + * @param \WP_REST_Request $request + * @return bool|\WP_Error + */ + public function check_permission(\WP_REST_Request $request) + { + $wpAbility = $this->getWpAbility(); + if (! $wpAbility) { + return false; + } + + return $wpAbility->check_permissions($this->getNormalizedAbilityInput($request)); + } + + /** + * Formats the incoming request input as per the expected ability schema. + * + * @param \WP_REST_Request $request + * @return mixed|null + */ + protected function getNormalizedAbilityInput(\WP_REST_Request $request) + { + $params = $input = $request->get_params(); + $inputSchema = $this->ability->inputSchema; + + if ($this->isScalarSchema($inputSchema)) { + $paramName = $this->extractUrlParamName(); + $input = $params[$paramName] ?? null; + } + + return $input; + } + + /** + * Handles the REST request by delegating to the ability's executeCallback. + * + * Uses smart param bridging: + * - Scalar inputSchema: extracts the single URL param value and passes it directly + * - Object inputSchema: passes the full params array + * + * @since 6.1.0 + * + * @param \WP_REST_Request $request + * @return \WP_REST_Response|\WP_Error + */ + public function handle_request(\WP_REST_Request $request) + { + try { + $ability = $this->getWpAbility(); + if (! $ability) { + return new \WP_Error( 'rest_no_ability', __( 'The ability is not registered.', 'wc-plugin-framework' ), ['status' => 500] ); + } + + $result = $ability->execute($this->getNormalizedAbilityInput($request)); + + return rest_ensure_response($result); + } catch (\Exception $e) { + return new \WP_Error( + 'ability_execution_error', + $e->getMessage(), + ['status' => $e->getCode() ?: 500] + ); + } + } + + /** + * Builds WP REST route arg definitions from the ability's inputSchema. + * + * - Object schema with properties: each property becomes a route arg + * - Scalar schema: extracts param name from URL regex pattern, creates single arg + * + * @since 6.1.0 + * + * @return array> + */ + protected function build_route_args() : array + { + $inputSchema = $this->ability->inputSchema; + + if (empty($inputSchema)) { + return []; + } + + $schemaType = $inputSchema['type'] ?? null; + + if ($schemaType === 'object' && ! empty($inputSchema['properties'])) { + return $inputSchema['properties']; + } + + if ($this->isScalarSchema($inputSchema)) { + $paramName = $this->extractUrlParamName(); + + if ($paramName) { + return [ + $paramName => [ + 'type' => $schemaType, + 'required' => true, + ], + ]; + } + } + + return []; + } + + /** + * Returns the JSON Schema for this endpoint's response. + * + * @since 6.1.0 + * + * @return array + */ + public function get_item_schema() : array + { + if ($this->schema) { + return $this->add_additional_fields_schema($this->schema); + } + + $outputSchema = $this->ability->outputSchema; + + $this->schema = [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->ability->name, + 'type' => $outputSchema['type'] ?? 'object', + ]; + + if (! empty($outputSchema['properties'])) { + $this->schema['properties'] = $outputSchema['properties']; + } + + return $this->add_additional_fields_schema($this->schema); + } + + /** + * Determines whether the inputSchema describes a scalar (non-object) type. + * + * @since 6.1.0 + * + * @param array $schema + * @return bool + */ + protected function isScalarSchema(array $schema) : bool + { + $type = $schema['type'] ?? null; + + return $type !== null && $type !== 'object' && $type !== 'array'; + } + + /** + * Extracts the first named capture group from the route's URL regex pattern. + * + * For example, from "memberships/plans/(?P\d+)" extracts "plan_id". + * + * @since 6.1.0 + * + * @return string|null + */ + protected function extractUrlParamName() : ?string + { + if (preg_match('/\(\?P<(\w+)>/', $this->rest_base, $matches)) { + return $matches[1]; + } + + return null; + } +} From fb5f053ad3e9630d15345d4e8f4bb53988832086 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 5 Mar 2026 12:54:46 +0000 Subject: [PATCH 04/16] Add JsonSerializable --- .../Abilities/Contracts/JsonSerializable.php | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 woocommerce/Abilities/Contracts/JsonSerializable.php diff --git a/woocommerce/Abilities/Contracts/JsonSerializable.php b/woocommerce/Abilities/Contracts/JsonSerializable.php new file mode 100644 index 000000000..0ceb231da --- /dev/null +++ b/woocommerce/Abilities/Contracts/JsonSerializable.php @@ -0,0 +1,25 @@ + Date: Thu, 5 Mar 2026 13:00:05 +0000 Subject: [PATCH 05/16] Clean up class --- woocommerce/Abilities/AbilitiesHandler.php | 2 +- woocommerce/Abilities/DataObjects/Ability.php | 25 ------------------- 2 files changed, 1 insertion(+), 26 deletions(-) diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index 971f6f8af..6c4cc6bbf 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -84,7 +84,7 @@ public function handleAbilitiesInit() : void } foreach ($this->abilitiesProvider->getAbilities() as $ability) { - wp_register_ability($ability->getName(), $ability->toArray()); + wp_register_ability($ability->name, $ability->toArray()); } } diff --git a/woocommerce/Abilities/DataObjects/Ability.php b/woocommerce/Abilities/DataObjects/Ability.php index 9193c4147..40ac5b334 100644 --- a/woocommerce/Abilities/DataObjects/Ability.php +++ b/woocommerce/Abilities/DataObjects/Ability.php @@ -45,19 +45,6 @@ class Ability /** @var ?RestApiConfig future REST API configuration (not acted on in this PoC) */ public ?RestApiConfig $restApiConfig; - /** - * @param string $name unique ability name - * @param string $label human-readable label - * @param string $description description of what the ability does - * @param string $category category slug this ability belongs to - * @param callable $executeCallback callback that executes the ability - * @param callable $permissionCallback callback that checks permissions - * @param array $inputSchema JSON Schema for the expected input - * @param array $outputSchema JSON Schema for the output - * @param ?AbilityAnnotations $annotations behavioral annotations - * @param bool $showInRest whether to expose in the REST API - * @param ?RestApiConfig $restApiConfig future REST API configuration - */ public function __construct( string $name, string $label, @@ -85,18 +72,6 @@ public function __construct( $this->restApiConfig = $restApiConfig; } - /** - * Gets the ability name. - * - * @since 6.1.0 - * - * @return string - */ - public function getName() : string - { - return $this->name; - } - /** * Maps properties to the structure expected by wp_register_ability(), * including the nested meta.annotations and meta.show_in_rest format. From 2edfe2856978029643e496ff34c4831890113219 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 5 Mar 2026 13:02:50 +0000 Subject: [PATCH 06/16] Use trait --- .../DataObjects/AbilityAnnotations.php | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/woocommerce/Abilities/DataObjects/AbilityAnnotations.php b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php index 5b63d23b2..47d44f472 100644 --- a/woocommerce/Abilities/DataObjects/AbilityAnnotations.php +++ b/woocommerce/Abilities/DataObjects/AbilityAnnotations.php @@ -2,6 +2,8 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_0_1\Abilities\DataObjects; +use SkyVerge\WooCommerce\PluginFramework\v6_0_1\Traits\CanConvertToArrayTrait; + /** * Data object representing ability behavior annotations. * @@ -12,6 +14,8 @@ */ class AbilityAnnotations { + use CanConvertToArrayTrait; + /** @var bool whether the ability only reads data and has no side effects */ public bool $readonly; @@ -31,18 +35,4 @@ public function __construct( $this->destructive = $destructive; $this->idempotent = $idempotent; } - - /** - * Returns the array format expected by WordPress. - * - * @since 6.1.0 - */ - public function toArray() : array - { - return [ - 'readonly' => $this->readonly, - 'destructive' => $this->destructive, - 'idempotent' => $this->idempotent, - ]; - } } From c9eddce229822aa9c4581c1b5933aa503315ab8e Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Thu, 5 Mar 2026 15:43:46 +0000 Subject: [PATCH 07/16] Simplify schema --- .../Abilities/REST/AbilityRestController.php | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/woocommerce/Abilities/REST/AbilityRestController.php b/woocommerce/Abilities/REST/AbilityRestController.php index 6a75ab1bd..3c8778e14 100644 --- a/woocommerce/Abilities/REST/AbilityRestController.php +++ b/woocommerce/Abilities/REST/AbilityRestController.php @@ -178,17 +178,13 @@ public function get_item_schema() : array return $this->add_additional_fields_schema($this->schema); } - $outputSchema = $this->ability->outputSchema; - - $this->schema = [ - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => $this->ability->name, - 'type' => $outputSchema['type'] ?? 'object', - ]; - - if (! empty($outputSchema['properties'])) { - $this->schema['properties'] = $outputSchema['properties']; - } + $this->schema = array_merge( + [ + '$schema' => 'http://json-schema.org/draft-04/schema#', + 'title' => $this->ability->name, + ], + $this->ability->outputSchema + ); return $this->add_additional_fields_schema($this->schema); } From 975b32b314491ed6cecf9061702c9e3c410c22c0 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 12:58:09 +0000 Subject: [PATCH 08/16] Update changelog --- woocommerce/changelog.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/woocommerce/changelog.txt b/woocommerce/changelog.txt index e85ceb912..49d04ff9b 100644 --- a/woocommerce/changelog.txt +++ b/woocommerce/changelog.txt @@ -1,6 +1,7 @@ *** SkyVerge WooCommerce Plugin Framework Changelog *** 2026.nn.nn - version 6.1.0 +* New: Introduced Abilities abstraction layers 2025.nn.nn - version 6.0.1 * New: Introduced a new ScriptHelper class with addInlineScript() method From 07e973810227644ad470848dfb199aef65e0d59f Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 13:14:29 +0000 Subject: [PATCH 09/16] Add file headings & improve docblocks --- woocommerce/Abilities/AbilitiesHandler.php | 35 +++++++++++++----- .../Abilities/AbstractAbilitiesProvider.php | 34 +++++++++++++---- .../Contracts/AbilitiesProviderContract.php | 37 +++++++++++++++++++ .../Contracts/HasAbilitiesContract.php | 28 +++++++++++++- .../Abilities/Contracts/JsonSerializable.php | 32 +++++++++++++--- .../Contracts/MakesAbilityContract.php | 27 ++++++++++++-- woocommerce/Abilities/DataObjects/Ability.php | 28 +++++++++++++- .../DataObjects/AbilityAnnotations.php | 22 +++++++++++ .../Abilities/DataObjects/AbilityCategory.php | 24 ++++++++++++ .../Abilities/DataObjects/RestApiConfig.php | 22 +++++++++++ woocommerce/class-sv-wc-plugin.php | 3 +- 11 files changed, 262 insertions(+), 30 deletions(-) diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index 2d8bf4f6b..d54dfd877 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -1,4 +1,26 @@ abilitiesProvider = $abilitiesProvider; - - $this->addHooks(); } /** @@ -39,7 +58,7 @@ public function __construct(AbilitiesProviderContract $abilitiesProvider) * * @return void */ - protected function addHooks() : void + public function addHooks() : void { add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); @@ -47,9 +66,7 @@ protected function addHooks() : void } /** - * Handles the categories init hook. - * - * Lazily collects abilities from the plugin, then registers categories with WordPress. + * Handles the categories init hook by registering ability categories with WordPress. * * @internal * diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php index 2bbab26a6..0fb6793b2 100644 --- a/woocommerce/Abilities/AbstractAbilitiesProvider.php +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -1,4 +1,26 @@ [] FQCNs of classes implementing MakesAbilityContract */ protected array $abilities = []; /** @@ -39,24 +61,20 @@ public function __construct(SV_WC_Plugin $plugin) $this->plugin = $plugin; } - /** - * @return AbilityCategory[] - */ + /** @inheritDoc */ public function getCategories() : array { return []; } - /** - * @return Ability[] - */ + /** @inheritDoc */ public function getAbilities() : array { $abilities = []; foreach ($this->abilities as $className) { if (! is_string($className) || ! in_array(MakesAbilityContract::class, class_implements($className) ?: [], true)) { - _doing_it_wrong( + wc_doing_it_wrong( __METHOD__, sprintf('Ability class "%s" must implement %s.', $className, MakesAbilityContract::class), '6.1.0' diff --git a/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php index ed142fc4e..ebe634156 100644 --- a/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php +++ b/woocommerce/Abilities/Contracts/AbilitiesProviderContract.php @@ -1,18 +1,55 @@ JSON Schema array (e.g. with 'type', 'properties', etc.) */ public static function getJsonSchema() : array; } diff --git a/woocommerce/Abilities/Contracts/MakesAbilityContract.php b/woocommerce/Abilities/Contracts/MakesAbilityContract.php index 10142c888..c1eaaa0b9 100644 --- a/woocommerce/Abilities/Contracts/MakesAbilityContract.php +++ b/woocommerce/Abilities/Contracts/MakesAbilityContract.php @@ -1,14 +1,33 @@ abilities_handler = new Abilities\AbilitiesHandler($this->getAbilitiesProvider()); + $this->abilities_handler->addHooks(); } } From a6a8c787badd221c5172b1943b83de4b37a293a7 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 13:21:55 +0000 Subject: [PATCH 10/16] Remove REST references --- woocommerce/Abilities/AbilitiesHandler.php | 23 -- woocommerce/Abilities/DataObjects/Ability.php | 7 +- .../Abilities/DataObjects/RestApiConfig.php | 56 ----- .../Abilities/REST/AbilityRestController.php | 224 ------------------ 4 files changed, 1 insertion(+), 309 deletions(-) delete mode 100644 woocommerce/Abilities/DataObjects/RestApiConfig.php delete mode 100644 woocommerce/Abilities/REST/AbilityRestController.php diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index d54dfd877..0f79fc894 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -25,7 +25,6 @@ namespace SkyVerge\WooCommerce\PluginFramework\v6_1_0\Abilities; use SkyVerge\WooCommerce\PluginFramework\v6_1_0\Abilities\Contracts\AbilitiesProviderContract; -use SkyVerge\WooCommerce\PluginFramework\v6_1_0\Abilities\REST\AbilityRestController; /** * Central handler for the WordPress Abilities API. @@ -62,7 +61,6 @@ public function addHooks() : void { add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); - add_action('rest_api_init', [$this, 'handleRestApiInit']); } /** @@ -104,25 +102,4 @@ public function handleAbilitiesInit() : void wp_register_ability($ability->name, $ability->toArray()); } } - - /** - * Handles the REST API init hook by auto-registering endpoints for abilities that have a RestApiConfig. - * - * @internal - * - * @since 6.1.0 - * - * @return void - */ - public function handleRestApiInit() : void - { - foreach ($this->abilitiesProvider->getAbilities() as $ability) { - if ($ability->restApiConfig === null) { - continue; - } - - $controller = new AbilityRestController($ability); - $controller->register_routes(); - } - } } diff --git a/woocommerce/Abilities/DataObjects/Ability.php b/woocommerce/Abilities/DataObjects/Ability.php index f17067dbb..89e431ea3 100644 --- a/woocommerce/Abilities/DataObjects/Ability.php +++ b/woocommerce/Abilities/DataObjects/Ability.php @@ -66,9 +66,6 @@ class Ability /** @var bool whether this ability should be exposed in the REST API */ public bool $showInRest; - /** @var ?RestApiConfig future REST API configuration (not acted on in this PoC) */ - public ?RestApiConfig $restApiConfig; - public function __construct( string $name, string $label, @@ -79,8 +76,7 @@ public function __construct( array $inputSchema = [], array $outputSchema = [], ?AbilityAnnotations $annotations = null, - bool $showInRest = true, - ?RestApiConfig $restApiConfig = null + bool $showInRest = true ) { $this->name = $name; @@ -93,7 +89,6 @@ public function __construct( $this->outputSchema = $outputSchema; $this->annotations = $annotations; $this->showInRest = $showInRest; - $this->restApiConfig = $restApiConfig; } /** diff --git a/woocommerce/Abilities/DataObjects/RestApiConfig.php b/woocommerce/Abilities/DataObjects/RestApiConfig.php deleted file mode 100644 index d528780ca..000000000 --- a/woocommerce/Abilities/DataObjects/RestApiConfig.php +++ /dev/null @@ -1,56 +0,0 @@ -namespace = $namespace; - $this->apiRoute = $apiRoute; - $this->method = $method; - } -} diff --git a/woocommerce/Abilities/REST/AbilityRestController.php b/woocommerce/Abilities/REST/AbilityRestController.php deleted file mode 100644 index 40af14116..000000000 --- a/woocommerce/Abilities/REST/AbilityRestController.php +++ /dev/null @@ -1,224 +0,0 @@ -ability = $ability; - $this->namespace = $ability->restApiConfig->namespace; - $this->rest_base = $ability->restApiConfig->apiRoute; - } - - protected function getWpAbility() : ?\WP_Ability - { - if (! isset($this->wpAbility)) { - $this->wpAbility = wp_get_ability($this->ability->name); - } - - return $this->wpAbility; - } - - /** - * Registers the REST route derived from the ability's RestApiConfig. - * - * @since 6.1.0 - */ - public function register_routes() : void - { - register_rest_route( - $this->namespace, - '/' . $this->rest_base, - [ - [ - 'methods' => $this->ability->restApiConfig->method, - 'callback' => [$this, 'handle_request'], - 'permission_callback' => [$this, 'check_permission'], - 'args' => $this->build_route_args(), - ], - 'schema' => [$this, 'get_public_item_schema'], - ] - ); - } - - /** - * Delegates permission check to the ability's permissionCallback. - * - * @since 6.1.0 - * - * @param \WP_REST_Request $request - * @return bool|\WP_Error - */ - public function check_permission(\WP_REST_Request $request) - { - $wpAbility = $this->getWpAbility(); - if (! $wpAbility) { - return false; - } - - return $wpAbility->check_permissions($this->getNormalizedAbilityInput($request)); - } - - /** - * Formats the incoming request input as per the expected ability schema. - * - * @param \WP_REST_Request $request - * @return mixed|null - */ - protected function getNormalizedAbilityInput(\WP_REST_Request $request) - { - $params = $input = $request->get_params(); - $inputSchema = $this->ability->inputSchema; - - if ($this->isScalarSchema($inputSchema)) { - $paramName = $this->extractUrlParamName(); - $input = $params[$paramName] ?? null; - } - - return $input; - } - - /** - * Handles the REST request by delegating to the ability's executeCallback. - * - * Uses smart param bridging: - * - Scalar inputSchema: extracts the single URL param value and passes it directly - * - Object inputSchema: passes the full params array - * - * @since 6.1.0 - * - * @param \WP_REST_Request $request - * @return \WP_REST_Response|\WP_Error - */ - public function handle_request(\WP_REST_Request $request) - { - try { - $ability = $this->getWpAbility(); - if (! $ability) { - return new \WP_Error( 'rest_no_ability', __( 'The ability is not registered.', 'wc-plugin-framework' ), ['status' => 500] ); - } - - $result = $ability->execute($this->getNormalizedAbilityInput($request)); - - return rest_ensure_response($result); - } catch (\Exception $e) { - return new \WP_Error( - 'ability_execution_error', - $e->getMessage(), - ['status' => $e->getCode() ?: 500] - ); - } - } - - /** - * Builds WP REST route arg definitions from the ability's inputSchema. - * - * - Object schema with properties: each property becomes a route arg - * - Scalar schema: extracts param name from URL regex pattern, creates single arg - * - * @since 6.1.0 - * - * @return array> - */ - protected function build_route_args() : array - { - $inputSchema = $this->ability->inputSchema; - - if (empty($inputSchema)) { - return []; - } - - $schemaType = $inputSchema['type'] ?? null; - - if ($schemaType === 'object' && ! empty($inputSchema['properties'])) { - return $inputSchema['properties']; - } - - if ($this->isScalarSchema($inputSchema)) { - $paramName = $this->extractUrlParamName(); - - if ($paramName) { - return [ - $paramName => [ - 'type' => $schemaType, - 'required' => true, - ], - ]; - } - } - - return []; - } - - /** - * Returns the JSON Schema for this endpoint's response. - * - * @since 6.1.0 - * - * @return array - */ - public function get_item_schema() : array - { - if ($this->schema) { - return $this->add_additional_fields_schema($this->schema); - } - - $this->schema = array_merge( - [ - '$schema' => 'http://json-schema.org/draft-04/schema#', - 'title' => $this->ability->name, - ], - $this->ability->outputSchema - ); - - return $this->add_additional_fields_schema($this->schema); - } - - /** - * Determines whether the inputSchema describes a scalar (non-object) type. - * - * @since 6.1.0 - * - * @param array $schema - * @return bool - */ - protected function isScalarSchema(array $schema) : bool - { - $type = $schema['type'] ?? null; - - return $type !== null && $type !== 'object' && $type !== 'array'; - } - - /** - * Extracts the first named capture group from the route's URL regex pattern. - * - * For example, from "memberships/plans/(?P\d+)" extracts "plan_id". - * - * @since 6.1.0 - * - * @return string|null - */ - protected function extractUrlParamName() : ?string - { - if (preg_match('/\(\?P<(\w+)>/', $this->rest_base, $matches)) { - return $matches[1]; - } - - return null; - } -} From 27ec3a4d3a0a8116319dfa1f700a236f6ddb8503 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 13:28:43 +0000 Subject: [PATCH 11/16] Clean up unnecessary types --- woocommerce/Abilities/AbilitiesHandler.php | 8 -------- woocommerce/Abilities/AbstractAbilitiesProvider.php | 2 -- 2 files changed, 10 deletions(-) diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index 0f79fc894..f5edf3903 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -41,9 +41,7 @@ class AbilitiesHandler /** * Constructor. * - * @param AbilitiesProviderContract $abilitiesProvider * @since 6.1.0 - * */ public function __construct(AbilitiesProviderContract $abilitiesProvider) { @@ -54,8 +52,6 @@ public function __construct(AbilitiesProviderContract $abilitiesProvider) * Hooks into WordPress Abilities API initialization actions. * * @since 6.1.0 - * - * @return void */ public function addHooks() : void { @@ -69,8 +65,6 @@ public function addHooks() : void * @internal * * @since 6.1.0 - * - * @return void */ public function handleCategoriesInit() : void { @@ -89,8 +83,6 @@ public function handleCategoriesInit() : void * @internal * * @since 6.1.0 - * - * @return void */ public function handleAbilitiesInit() : void { diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php index 0fb6793b2..1bcea9663 100644 --- a/woocommerce/Abilities/AbstractAbilitiesProvider.php +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -53,8 +53,6 @@ abstract class AbstractAbilitiesProvider implements AbilitiesProviderContract * Constructor. * * @since 6.1.0 - * - * @param SV_WC_Plugin $plugin */ public function __construct(SV_WC_Plugin $plugin) { From 4649f6687b93c301421f1d9b958fac7359641aa3 Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 13:30:43 +0000 Subject: [PATCH 12/16] Centralize can use abilities API logic --- woocommerce/Abilities/AbilitiesHandler.php | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index f5edf3903..6a0079a15 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -55,10 +55,24 @@ public function __construct(AbilitiesProviderContract $abilitiesProvider) */ public function addHooks() : void { + if (! $this->canUseAbilitiesApi()) { + return; + } + add_action('wp_abilities_api_categories_init', [$this, 'handleCategoriesInit']); add_action('wp_abilities_api_init', [$this, 'handleAbilitiesInit']); } + /** + * Determines whether the site is able to use the Abilities API. Requires WordPress 6.9+. + * + * @since 6.1.0 + */ + protected function canUseAbilitiesApi() : bool + { + return function_exists('wp_register_ability') && function_exists('wp_register_ability_category'); + } + /** * Handles the categories init hook by registering ability categories with WordPress. * @@ -68,10 +82,6 @@ public function addHooks() : void */ public function handleCategoriesInit() : void { - if (! function_exists('wp_register_ability_category')) { - return; - } - foreach ($this->abilitiesProvider->getCategories() as $category) { wp_register_ability_category($category->slug, $category->toArray()); } @@ -86,10 +96,6 @@ public function handleCategoriesInit() : void */ public function handleAbilitiesInit() : void { - if (! function_exists('wp_register_ability')) { - return; - } - foreach ($this->abilitiesProvider->getAbilities() as $ability) { wp_register_ability($ability->name, $ability->toArray()); } From 45453dd22f95c689d0823bfc980cbef3d961fc7c Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 14:33:51 +0000 Subject: [PATCH 13/16] Add tests --- tests/unit/Abilities/AbilitiesHandlerTest.php | 140 ++++++++++++++++++ .../AbstractAbilitiesProviderTest.php | 119 +++++++++++++++ woocommerce/Abilities/AbilitiesHandler.php | 2 + .../Abilities/AbstractAbilitiesProvider.php | 15 +- 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 tests/unit/Abilities/AbilitiesHandlerTest.php create mode 100644 tests/unit/Abilities/AbstractAbilitiesProviderTest.php diff --git a/tests/unit/Abilities/AbilitiesHandlerTest.php b/tests/unit/Abilities/AbilitiesHandlerTest.php new file mode 100644 index 000000000..5fd97337c --- /dev/null +++ b/tests/unit/Abilities/AbilitiesHandlerTest.php @@ -0,0 +1,140 @@ +assertSame($provider, $this->getInaccessiblePropertyValue($handler, 'abilitiesProvider')); + } + + /** + * @covers ::addHooks + * @dataProvider providerCanAddHooks + */ + public function testCanAddHooks(bool $canUseApi) : void + { + $handler = $this->createPartialMock(AbilitiesHandler::class, ['canUseAbilitiesApi']); + + $handler->expects($this->once()) + ->method('canUseAbilitiesApi') + ->willReturn($canUseApi); + + if ($canUseApi) { + WP_Mock::expectActionAdded('wp_abilities_api_categories_init', [$handler, 'handleCategoriesInit']); + WP_Mock::expectActionAdded('wp_abilities_api_init', [$handler, 'handleAbilitiesInit']); + } else { + WP_Mock::expectActionNotAdded('wp_abilities_api_categories_init', [$handler, 'handleCategoriesInit']); + WP_Mock::expectActionNotAdded('wp_abilities_api_init', [$handler, 'handleAbilitiesInit']); + } + + $handler->addHooks(); + + $this->assertConditionsMet(); + } + + /** @see testCanAddHooks */ + public function providerCanAddHooks() : Generator + { + yield 'can use api' => [true]; + yield 'cannot use api' => [false]; + } + + /** + * @covers ::handleCategoriesInit + */ + public function testCanHandleCategoriesInit() : void + { + $category = new AbilityCategory('memberships', 'WooCommerce Memberships', 'Description'); + + $provider = Mockery::mock(AbilitiesProviderContract::class); + $provider->expects('getCategories') + ->once() + ->andReturn([$category]); + + $handler = new AbilitiesHandler($provider); + + WP_Mock::userFunction('wp_register_ability_category') + ->once() + ->with('memberships', [ + 'label' => 'WooCommerce Memberships', + 'description' => 'Description', + 'meta' => [], + ]); + + $handler->handleCategoriesInit(); + + $this->assertConditionsMet(); + } + + /** + * @covers ::handleAbilitiesInit + */ + public function testCanHandleAbilitiesInit(): void + { + $ability = new Ability( + 'woocommerce-memberships/create-plan', + 'Create Membership Plan', + 'Create a new plan.', + 'woocommerce-memberships', + fn() => true, + fn() => false, + ['type' => 'integer'], + ['type' => 'boolean'], + new AbilityAnnotations(false, false, false) + ); + + $provider = Mockery::mock(AbilitiesProviderContract::class); + $provider->expects('getAbilities') + ->once() + ->andReturn([$ability]); + + $handler = new AbilitiesHandler($provider); + + WP_Mock::userFunction('wp_register_ability') + ->once() + ->with('woocommerce-memberships/create-plan', [ + 'label' => $ability->label, + 'description' => $ability->description, + 'category' => $ability->category, + 'execute_callback' => $ability->executeCallback, + 'permission_callback' => $ability->permissionCallback, + 'input_schema' => $ability->inputSchema, + 'output_schema' => $ability->outputSchema, + 'meta' => [ + 'show_in_rest' => true, + 'annotations' => [ + 'readonly' => false, + 'destructive' => false, + 'idempotent' => false, + ], + ], + ]); + + $handler->handleAbilitiesInit(); + + $this->assertConditionsMet(); + } +} diff --git a/tests/unit/Abilities/AbstractAbilitiesProviderTest.php b/tests/unit/Abilities/AbstractAbilitiesProviderTest.php new file mode 100644 index 000000000..d1757d19f --- /dev/null +++ b/tests/unit/Abilities/AbstractAbilitiesProviderTest.php @@ -0,0 +1,119 @@ +assertSame($plugin, $this->getInaccessiblePropertyValue($concrete, 'plugin')); + } + + /** + * @covers ::getCategories + * @throws Exception + */ + public function testCanGetCategories() : void + { + $this->assertSame( + [], + $this->invokeInaccessibleMethod( + $this->getMockForAbstractClass(AbstractAbilitiesProvider::class, [], '', false), + 'getCategories' + ) + ); + } + + /** + * @covers ::getAbilities + * @throws Exception + */ + public function testCanGetAbilities() : void + { + $abilityMaker = Mockery::mock(MakesAbilityContract::class); + $abilityMakerClassName = get_class($abilityMaker); + + $abilityMaker->expects('makeAbility') + ->once() + ->andReturn($ability = Mockery::mock(Ability::class)); + + $abstractProvider = $this->getMockForAbstractClass( + AbstractAbilitiesProvider::class, + [], + '', + false, + false, + true, + ['instantiateAbilityClass'] + ); + + $this->setInaccessiblePropertyValue($abstractProvider, 'abilities', [$abilityMakerClassName]); + + $abstractProvider->expects($this->once()) + ->method('instantiateAbilityClass') + ->with(get_class($abilityMaker)) + ->willReturn($abilityMaker); + + $this->assertSame( + [$ability], + $this->invokeInaccessibleMethod($abstractProvider, 'getAbilities') + ); + } + + /** + * @covers ::getAbilities + * @throws Exception + */ + public function testCanGetAbilitiesWithInvalidAbilityMaker() : void + { + $abilityMaker = new \stdClass(); + $abilityMakerClassName = get_class($abilityMaker); + + $abstractProvider = $this->getMockForAbstractClass( + AbstractAbilitiesProvider::class, + [], + '', + false, + false, + true, + ['instantiateAbilityClass'] + ); + + $this->setInaccessiblePropertyValue($abstractProvider, 'abilities', [$abilityMakerClassName]); + + $abstractProvider->expects($this->never()) + ->method('instantiateAbilityClass'); + + WP_Mock::userFunction('wc_doing_it_wrong') + ->once() + ->with( + sprintf('%s::getAbilities', AbstractAbilitiesProvider::class), + 'Ability class "stdClass" must implement '.MakesAbilityContract::class.'.', + '6.1.0' + ); + + $this->assertSame( + [], + $this->invokeInaccessibleMethod($abstractProvider, 'getAbilities') + ); + } +} diff --git a/woocommerce/Abilities/AbilitiesHandler.php b/woocommerce/Abilities/AbilitiesHandler.php index 6a0079a15..8f8f3b979 100644 --- a/woocommerce/Abilities/AbilitiesHandler.php +++ b/woocommerce/Abilities/AbilitiesHandler.php @@ -66,6 +66,8 @@ public function addHooks() : void /** * Determines whether the site is able to use the Abilities API. Requires WordPress 6.9+. * + * @codeCoverageIgnore + * * @since 6.1.0 */ protected function canUseAbilitiesApi() : bool diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php index 1bcea9663..4458b9877 100644 --- a/woocommerce/Abilities/AbstractAbilitiesProvider.php +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -80,9 +80,22 @@ public function getAbilities() : array continue; } - $abilities[] = (new $className)->makeAbility(); + $abilities[] = $this->instantiateAbilityClass($className)->makeAbility(); } return $abilities; } + + /** + * Instantiates the provided {@see MakesAbilityContract} class name. + * + * @codeCoverageIgnore + * + * @param class-string $className + * @return MakesAbilityContract + */ + protected function instantiateAbilityClass(string $className) : MakesAbilityContract + { + return new $className(); + } } From c936f0664f25418def3067b79679d90d5262137a Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 15:13:29 +0000 Subject: [PATCH 14/16] Add more tests --- .../DataObjects/AbilityAnnotationsTest.php | 41 +++++++++ .../DataObjects/AbilityCategoryTest.php | 42 +++++++++ .../Abilities/DataObjects/AbilityTest.php | 88 +++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php create mode 100644 tests/unit/Abilities/DataObjects/AbilityCategoryTest.php create mode 100644 tests/unit/Abilities/DataObjects/AbilityTest.php diff --git a/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php b/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php new file mode 100644 index 000000000..27e1603c8 --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityAnnotationsTest.php @@ -0,0 +1,41 @@ +assertTrue($abilityAnnotations->readonly); + $this->assertTrue($abilityAnnotations->destructive); + $this->assertTrue($abilityAnnotations->idempotent); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $abilityAnnotations = new AbilityAnnotations(true, true, true); + + $this->assertSame( + [ + 'readonly' => true, + 'destructive' => true, + 'idempotent' => true, + ], + $abilityAnnotations->toArray() + ); + } +} diff --git a/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php b/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php new file mode 100644 index 000000000..87ec5531b --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityCategoryTest.php @@ -0,0 +1,42 @@ + 'value']); + + $this->assertSame('ability-category', $category->slug); + $this->assertSame('Ability Category', $category->label); + $this->assertSame('Description...', $category->description); + $this->assertSame(['key' => 'value'], $category->meta); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $category = new AbilityCategory('ability-category', 'Ability Category', 'Description...', ['key' => 'value']); + + $this->assertSame( + [ + 'label' => $category->label, + 'description' => $category->description, + 'meta' => $category->meta, + ], + $category->toArray() + ); + } +} diff --git a/tests/unit/Abilities/DataObjects/AbilityTest.php b/tests/unit/Abilities/DataObjects/AbilityTest.php new file mode 100644 index 000000000..eb3ef0556 --- /dev/null +++ b/tests/unit/Abilities/DataObjects/AbilityTest.php @@ -0,0 +1,88 @@ + true; + $permissionCallback = fn() => false; + + $annotations = Mockery::mock(AbilityAnnotations::class); + + $ability = new Ability( + 'ability/name', + 'Ability Name', + 'Description...', + 'category-slug', + $executeCallback, + $permissionCallback, + ['type' => 'integer'], + ['type' => 'object'], + $annotations, + false + ); + + $this->assertSame('ability/name', $ability->name); + $this->assertSame('Ability Name', $ability->label); + $this->assertSame('Description...', $ability->description); + $this->assertSame($executeCallback, $ability->executeCallback); + $this->assertSame($permissionCallback, $ability->permissionCallback); + $this->assertSame(['type' => 'integer'], $ability->inputSchema); + $this->assertSame(['type' => 'object'], $ability->outputSchema); + $this->assertSame($annotations, $ability->annotations); + $this->assertFalse($ability->showInRest); + } + + /** + * @covers ::toArray + */ + public function testCanConvertToArray() : void + { + $ability = new Ability( + 'ability/name', + 'Ability Name', + 'Description...', + 'category-slug', + fn() => true, + fn() => false, + ['type' => 'integer'], + ['type' => 'object'], + new AbilityAnnotations(true, false, true), + false + ); + + $this->assertSame( + [ + 'label' => $ability->label, + 'description' => $ability->description, + 'category' => $ability->category, + 'execute_callback' => $ability->executeCallback, + 'permission_callback' => $ability->permissionCallback, + 'input_schema' => $ability->inputSchema, + 'output_schema' => $ability->outputSchema, + 'meta' => [ + 'show_in_rest' => $ability->showInRest, + 'annotations' => [ + 'readonly' => true, + 'destructive' => false, + 'idempotent' => true, + ], + ], + ], + $ability->toArray() + ); + } +} From 5b73d45c37b61a2d5beb580a9940860bf7130243 Mon Sep 17 00:00:00 2001 From: Ashley Gibson <99189195+agibson-godaddy@users.noreply.github.com> Date: Mon, 9 Mar 2026 15:57:24 +0000 Subject: [PATCH 15/16] Fix docblock Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- woocommerce/Abilities/AbstractAbilitiesProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php index 4458b9877..888b9abc0 100644 --- a/woocommerce/Abilities/AbstractAbilitiesProvider.php +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -37,7 +37,7 @@ * logic in a dedicated provider, keeping the main plugin class clean. * * Subclasses list ability class names in the {@see $abilities} property and - * optionally override {@see registerCategories()} to register categories. + * optionally override {@see getCategories()} to register categories. * * @since 6.1.0 */ From dcde4cdd385af8589d2b99e3c2466f0f92db9fca Mon Sep 17 00:00:00 2001 From: Ashley Gibson Date: Mon, 9 Mar 2026 15:58:16 +0000 Subject: [PATCH 16/16] Check if class exists --- woocommerce/Abilities/AbstractAbilitiesProvider.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/woocommerce/Abilities/AbstractAbilitiesProvider.php b/woocommerce/Abilities/AbstractAbilitiesProvider.php index 888b9abc0..c28d5442b 100644 --- a/woocommerce/Abilities/AbstractAbilitiesProvider.php +++ b/woocommerce/Abilities/AbstractAbilitiesProvider.php @@ -71,7 +71,11 @@ public function getAbilities() : array $abilities = []; foreach ($this->abilities as $className) { - if (! is_string($className) || ! in_array(MakesAbilityContract::class, class_implements($className) ?: [], true)) { + if ( + ! is_string($className) || + ! class_exists($className) || + ! in_array(MakesAbilityContract::class, class_implements($className) ?: [], true) + ) { wc_doing_it_wrong( __METHOD__, sprintf('Ability class "%s" must implement %s.', $className, MakesAbilityContract::class),