Commit 695bbeaf4b8 for woocommerce
commit 695bbeaf4b8cf9610ee274d3c956b0509f64f6f3
Author: Luis Felipe Zaguini <26530524+zaguiini@users.noreply.github.com>
Date: Fri Oct 16 17:33:32 2026 -0300
Add namespaced extension fields to the product and order abilities (#69678)
* Add namespaced extension fields to the product and order abilities
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the resource parameter of the read methods
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the read methods to match the WordPress REST field methods
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Use did_action() to fire the ability extensions action one time
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Leave the schema unchanged when a resource has no extension fields
add_fields_schema() added an empty extensions property to every order and product schema. It now returns the schema unchanged when no field is registered for the resource.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Refuse a field that is registered late, on an unknown resource, or without a type
A field registered after the init action is returned but not listed in the
output schema, so every read of the resource fails validation. Refuse it
with a doing_it_wrong instead. Resources are registered with
register_resource() first, so a typo in the resource name is reported, and
a field schema needs a type.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Register the core resources in AbilityExtensions itself
The loader no longer registers product, order and order_item before it
fires woocommerce_ability_extensions_init. AbilityExtensions starts with
those three resources, so no hook priority can run before they exist.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Leave out a field whose value fails sanitization
A value can pass rest_validate_value_from_schema() and still make
rest_sanitize_value_from_schema() return a WP_Error. For example,
array( '12', 12 ) with integer items and uniqueItems validates, but
sanitization casts both to 12 and reports duplicate items. The error
was stored as the field value.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Register ability resources in their own action, before the fields
Core and extensions now register resources inside the new
woocommerce_ability_resources_init action. It fires before
woocommerce_ability_extensions_init, so a field can target any
resource, whatever the hook priorities are.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Register the core ability resources at priority 0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename the feature to ability extensions and add a prime callback for list reads
Fold the feature gate into AbilityExtensions::is_enabled() and rename the
experimental feature to ability_extensions, so the option, the class and the
init action share one name.
Add an optional prime_callback to register_field(). The product and order
list abilities call it one time with every object of the page, before the
get_callbacks run, so an extension loads its data in one query.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Remove the prime callback from ability extension fields
No extension needs it yet. An optional argument can be added later without breaking registered fields.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* Translate the wc_doing_it_wrong messages in AbilityExtensions
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename AbilityExtensions to ExtensibleAbilities
The name now describes Core's abilities, which extensions can extend.
"Ability extensions" could read as plugins that add abilities.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Rename woocommerce_ability_resources_init to woocommerce_extensible_abilities_resources_init
Both hooks of the API now share the feature prefix. The pair follows
wp_abilities_api_categories_init and wp_abilities_api_init.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Shorten the changelog entry to an overview of the feature
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Harden extension fields against bad registrations
- Refuse a field schema that sets required, so a dropped value
cannot fail the whole ability output.
- Refuse resource, namespace and field slugs made only of digits,
which PHP turns into integer keys and JSON encodes as a list.
- Close the extensions and namespace schema objects with
additionalProperties false, like the other output objects.
- Check for registered fields before the feature lookup.
- Point to the init action when a field is registered before the
resources exist.
- Log the left-out field message in English.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/add-ability-extension-fields b/plugins/woocommerce/changelog/add-ability-extension-fields
new file mode 100644
index 00000000000..23cbc8bf796
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-ability-extension-fields
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add the experimental extensible abilities feature. With it on, extensions register fields with ExtensibleAbilities::register_field(), and the abilities return their values under extensions.<namespace>.<field>.
diff --git a/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
new file mode 100644
index 00000000000..d922c69baad
--- /dev/null
+++ b/plugins/woocommerce/src/Abilities/ExtensibleAbilities.php
@@ -0,0 +1,301 @@
+<?php
+/**
+ * Extensible abilities class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Abilities;
+
+use Automattic\WooCommerce\Utilities\FeaturesUtil;
+
+defined( 'ABSPATH' ) || exit;
+
+/**
+ * The entry point for extensions in WooCommerce's abilities.
+ *
+ * An extension registers a resource inside the `woocommerce_extensible_abilities_resources_init`
+ * action. It registers a field on a resource, such as `product`, inside the
+ * `woocommerce_extensible_abilities_init` action, which fires after. An ability that formats an
+ * object of that resource returns the field values under
+ * `extensions.<namespace>.<field>`, and lists the fields in its output schema.
+ *
+ * The API is experimental while the `extensible_abilities` feature exists.
+ *
+ * @since 11.3.0
+ */
+class ExtensibleAbilities {
+
+ public const FEATURE_ID = 'extensible_abilities';
+
+ /**
+ * Fields keyed by resource, then namespace, then field name.
+ *
+ * @var array<string, array<string, array<string, array<string, mixed>>>>
+ */
+ private static array $fields = array();
+
+ /**
+ * Registered resources, keyed by resource name.
+ *
+ * @var array<string, bool>
+ */
+ private static array $resources = array();
+
+ /**
+ * Fields already reported for a value that does not match the schema, keyed by resource, namespace and field.
+ *
+ * @var array<string, bool>
+ */
+ private static array $reported = array();
+
+ /**
+ * Whether the store enabled the experimental feature.
+ *
+ * @since 11.3.0
+ *
+ * @return bool
+ */
+ public static function is_enabled(): bool {
+ return FeaturesUtil::feature_is_enabled( self::FEATURE_ID );
+ }
+
+ /**
+ * Register a resource that abilities format, so extensions can register fields on it.
+ *
+ * Call it inside the `woocommerce_extensible_abilities_resources_init` action. A call after that action is refused.
+ * Core registers `product`, `order` and `order_item` there. An
+ * extension registers a resource that its own abilities format, and then calls `add_fields_to_object()`
+ * and `add_fields_schema()` from those abilities, exactly as Core's abilities do.
+ * Registering the same resource again does nothing.
+ *
+ * @since 11.3.0
+ *
+ * @param string $resource_name Slug of the resource, such as `ticket`.
+ */
+ public static function register_resource( string $resource_name ): void {
+ if ( did_action( 'woocommerce_extensible_abilities_resources_init' ) && ! doing_action( 'woocommerce_extensible_abilities_resources_init' ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'Register a resource inside the woocommerce_extensible_abilities_resources_init action, before the fields are registered.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( '' === $resource_name || sanitize_key( $resource_name ) !== $resource_name || ctype_digit( $resource_name ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The resource name must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+
+ self::$resources[ $resource_name ] = true;
+ }
+
+ /**
+ * Register a field that the abilities of a resource return under `extensions.<namespace>.<field>`.
+ *
+ * Call it inside the `woocommerce_extensible_abilities_init` action. A call after that action is refused.
+ * The resource must be registered with `register_resource()` in the earlier `woocommerce_extensible_abilities_resources_init` action.
+ *
+ * @since 11.3.0
+ *
+ * @param array $args {
+ * Field arguments.
+ *
+ * @type string $resource Resource that an ability formats, for example `product`, `order` or `order_item`.
+ * @type string $namespace Slug of the extension that owns the field, such as `subscriptions`.
+ * @type string $field Field name inside the namespace, such as `trial_length`.
+ * @type array $schema JSON schema of the value. It needs a `type`. Its `title` is the label clients show.
+ * It cannot set `required`, because a value that fails its schema is
+ * left out. For a field that is always present, allow null and return null.
+ * @type callable $get_callback Receives the object and returns the value. A value that the schema
+ * does not allow is left out, so return null when the object has no
+ * value. A schema that allows null, such as `array( 'integer', 'null' )`,
+ * keeps it. It reads the object it is given, not the database,
+ * because an ability can format an object before it is saved.
+ * }
+ */
+ public static function register_field( array $args ): void {
+ if ( did_action( 'woocommerce_extensible_abilities_init' ) && ! doing_action( 'woocommerce_extensible_abilities_init' ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'Register a field inside the woocommerce_extensible_abilities_init action, before the abilities build their schemas.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ foreach ( array( 'resource', 'namespace', 'field' ) as $key ) {
+ if ( ! is_string( $args[ $key ] ?? null ) || '' === $args[ $key ] || sanitize_key( $args[ $key ] ) !== $args[ $key ] || ctype_digit( $args[ $key ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: argument name. */
+ __( 'The "%s" argument must be a slug of lowercase letters, numbers, dashes and underscores, not only numbers.', 'woocommerce' ),
+ $key
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+ }
+ if ( ! isset( self::$resources[ $args['resource'] ] ) && ! did_action( 'woocommerce_extensible_abilities_resources_init' ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'Register a field inside the woocommerce_extensible_abilities_init action. The resources are not registered yet.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( ! isset( self::$resources[ $args['resource'] ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: %s: resource name. */
+ __( 'The "%s" resource is not registered. Call ExtensibleAbilities::register_resource() first.', 'woocommerce' ),
+ $args['resource']
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+ if ( ! is_array( $args['schema'] ?? null ) || ! isset( $args['schema']['type'] ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument must be an array with a "type".', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( true === ( $args['schema']['required'] ?? null ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "schema" argument cannot set "required". A value that fails its schema is left out, so allow null for a field that is always present.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( ! is_callable( $args['get_callback'] ?? null ) ) {
+ wc_doing_it_wrong( __METHOD__, __( 'The "get_callback" argument must be callable.', 'woocommerce' ), '11.3.0' );
+ return;
+ }
+ if ( isset( self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] ) ) {
+ wc_doing_it_wrong(
+ __METHOD__,
+ sprintf(
+ /* translators: 1: extension namespace, 2: field name, 3: resource name. */
+ __( 'The field "%1$s.%2$s" of "%3$s" is already registered.', 'woocommerce' ),
+ $args['namespace'],
+ $args['field'],
+ $args['resource']
+ ),
+ '11.3.0'
+ );
+ return;
+ }
+
+ self::$fields[ $args['resource'] ][ $args['namespace'] ][ $args['field'] ] = $args;
+ }
+
+ /**
+ * Add the field values of an object to its formatted output, under
+ * `extensions.<namespace>.<field>`. A value is cast to its schema type, so
+ * `'12'` becomes `12` for an integer field. A field that throws or returns a
+ * value that its schema does not allow is left out. A namespace with no
+ * values is left out, and `extensions` is left out when no namespace has
+ * values or the feature is off.
+ *
+ * @since 11.3.0
+ *
+ * @param array<string, mixed> $output Formatted object.
+ * @param string $resource_name Resource.
+ * @param object $subject Object to read.
+ * @return array<string, mixed>
+ */
+ public static function add_fields_to_object( array $output, string $resource_name, $subject ): array {
+ if ( empty( self::$fields[ $resource_name ] ) || ! self::is_enabled() ) {
+ return $output;
+ }
+
+ $extensions = array();
+ foreach ( self::$fields[ $resource_name ] as $namespace => $fields ) {
+ foreach ( $fields as $field => $args ) {
+ try {
+ $value = call_user_func( $args['get_callback'], $subject );
+ } catch ( \Throwable $e ) {
+ wc_get_logger()->error(
+ sprintf( 'Ability field "%s.%s" of "%s" failed: %s', $namespace, $field, $resource_name, $e->getMessage() ),
+ array( 'source' => 'extensible-abilities' )
+ );
+ continue;
+ }
+ if ( ! self::matches_schema( $args, $value ) ) {
+ continue;
+ }
+ $sanitized = rest_sanitize_value_from_schema( $value, $args['schema'], $field );
+ if ( ! is_wp_error( $sanitized ) ) {
+ $extensions[ $namespace ][ $field ] = $sanitized;
+ }
+ }
+ }
+ if ( ! empty( $extensions ) ) {
+ $output['extensions'] = $extensions;
+ }
+ return $output;
+ }
+
+ /**
+ * Add the `extensions` property to an object schema when the feature is on.
+ * It lists each namespace with its fields. Call it after the
+ * `woocommerce_extensible_abilities_init` action, when the ability builds its
+ * schema. The schema stays unchanged when no field is registered for the resource.
+ *
+ * @since 11.3.0
+ *
+ * @param array<string, mixed> $schema Object schema.
+ * @param string $resource_name Resource.
+ * @return array<string, mixed>
+ */
+ public static function add_fields_schema( array $schema, string $resource_name ): array {
+ if ( ! self::is_enabled() || empty( self::$fields[ $resource_name ] ) ) {
+ return $schema;
+ }
+
+ $extensions = array(
+ 'type' => 'object',
+ 'description' => __( 'Values that extensions add, keyed by extension namespace, then field.', 'woocommerce' ),
+ 'additionalProperties' => false,
+ );
+ foreach ( self::$fields[ $resource_name ] ?? array() as $namespace => $fields ) {
+ $extensions['properties'][ $namespace ] = array(
+ 'type' => 'object',
+ 'properties' => array_map(
+ static function ( array $args ): array {
+ return $args['schema'];
+ },
+ $fields
+ ),
+ 'additionalProperties' => false,
+ );
+ }
+ $schema['properties']['extensions'] = $extensions;
+ return $schema;
+ }
+
+ /**
+ * Whether a value matches its field schema. A value other than null that
+ * does not match is logged and reported one time for each field.
+ *
+ * @param array<string, mixed> $args Field arguments.
+ * @param mixed $value Value that the get_callback returned.
+ * @return bool
+ */
+ private static function matches_schema( array $args, $value ): bool {
+ $valid = rest_validate_value_from_schema( $value, $args['schema'], $args['field'] );
+ if ( true === $valid ) {
+ return true;
+ }
+
+ $key = $args['resource'] . '.' . $args['namespace'] . '.' . $args['field'];
+ if ( null === $value || isset( self::$reported[ $key ] ) ) {
+ return false;
+ }
+
+ self::$reported[ $key ] = true;
+ wc_get_logger()->error(
+ sprintf( 'Ability field "%s.%s" of "%s" was left out because its value does not match its schema: %s', $args['namespace'], $args['field'], $args['resource'], $valid->get_error_message() ),
+ array( 'source' => 'extensible-abilities' )
+ );
+ wc_doing_it_wrong(
+ __CLASS__ . '::register_field',
+ sprintf(
+ /* translators: 1: extension namespace, 2: field name, 3: resource name, 4: validation error. */
+ __( 'Ability field "%1$s.%2$s" of "%3$s" was left out because its value does not match its schema: %4$s', 'woocommerce' ),
+ $args['namespace'],
+ $args['field'],
+ $args['resource'],
+ $valid->get_error_message()
+ ),
+ '11.3.0'
+ );
+ return false;
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
index f7a09fefb09..55469e16f29 100644
--- a/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
+++ b/plugins/woocommerce/src/Internal/Abilities/AbilitiesLoader.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities;
use Automattic\WooCommerce\Abilities\AbilityDefinition;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderAddNote;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderNotesQuery;
use Automattic\WooCommerce\Internal\Abilities\Domain\OrderUpdateStatus;
@@ -78,12 +79,60 @@ class AbilitiesLoader {
* Support both old (pre-6.9) and new (6.9+) action names.
*/
AbilitiesCategories::init();
+ add_action( 'woocommerce_extensible_abilities_resources_init', array( __CLASS__, 'register_core_resources' ), 0 );
+ add_action( 'abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
+ add_action( 'wp_abilities_api_init', array( __CLASS__, 'init_extensions' ), 0 );
add_action( 'abilities_api_init', array( __CLASS__, 'register_abilities' ) );
add_action( 'wp_abilities_api_init', array( __CLASS__, 'register_abilities' ) );
self::$initialized = true;
}
+ /**
+ * Register the resources that Core's abilities format, so extensions can register fields on them.
+ *
+ * @internal
+ *
+ * @since 11.3.0
+ */
+ public static function register_core_resources(): void {
+ foreach ( array( 'product', 'order', 'order_item' ) as $resource_name ) {
+ ExtensibleAbilities::register_resource( $resource_name );
+ }
+ }
+
+ /**
+ * Let extensions register their resources, then their ability fields, before the abilities build their schemas.
+ *
+ * @internal
+ *
+ * @since 11.3.0
+ */
+ public static function init_extensions(): void {
+ if ( did_action( 'woocommerce_extensible_abilities_init' ) || ! ExtensibleAbilities::is_enabled() ) {
+ return;
+ }
+
+ /**
+ * Fires when extensions can register their resources with ExtensibleAbilities.
+ *
+ * Fires one time, before `woocommerce_extensible_abilities_init`, only when the
+ * experimental `extensible_abilities` feature is on.
+ *
+ * @since 11.3.0
+ */
+ do_action( 'woocommerce_extensible_abilities_resources_init' );
+
+ /**
+ * Fires when extensions can register their fields with ExtensibleAbilities.
+ *
+ * Fires one time, only when the experimental `extensible_abilities` feature is on.
+ *
+ * @since 11.3.0
+ */
+ do_action( 'woocommerce_extensible_abilities_init' );
+ }
+
/**
* Register all configured ability definitions.
*
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
index 63ea7ba88a7..be62be7cc7e 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/OrderAbilityTrait.php
@@ -8,6 +8,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
use Automattic\WooCommerce\Enums\OrderStatus;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Internal\DataStores\Orders\DataSynchronizer;
use Automattic\WooCommerce\Utilities\OrderUtil;
@@ -125,7 +126,7 @@ trait OrderAbilityTrait {
* @return array
*/
protected static function get_order_output_schema(): array {
- return array(
+ $schema = array(
'type' => 'object',
'properties' => array(
'id' => array( 'type' => 'integer' ),
@@ -165,23 +166,27 @@ trait OrderAbilityTrait {
'line_items' => array(
'type' => 'array',
'description' => __( 'Order line items. Only present when include_line_items is true.', 'woocommerce' ),
- 'items' => array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'name' => array( 'type' => 'string' ),
- 'product_id' => array( 'type' => 'integer' ),
- 'variation_id' => array( 'type' => 'integer' ),
- 'quantity' => array( 'type' => 'integer' ),
- 'subtotal' => array( 'type' => 'string' ),
- 'total' => array( 'type' => 'string' ),
+ 'items' => ExtensibleAbilities::add_fields_schema(
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'product_id' => array( 'type' => 'integer' ),
+ 'variation_id' => array( 'type' => 'integer' ),
+ 'quantity' => array( 'type' => 'integer' ),
+ 'subtotal' => array( 'type' => 'string' ),
+ 'total' => array( 'type' => 'string' ),
+ ),
+ 'additionalProperties' => false,
),
- 'additionalProperties' => false,
+ 'order_item'
),
),
),
'additionalProperties' => false,
);
+ return ExtensibleAbilities::add_fields_schema( $schema, 'order' );
}
/**
@@ -290,18 +295,22 @@ trait OrderAbilityTrait {
continue;
}
- $data['line_items'][] = array(
- 'id' => $item->get_id(),
- 'name' => $item->get_name(),
- 'product_id' => $item->get_product_id(),
- 'variation_id' => $item->get_variation_id(),
- 'quantity' => $item->get_quantity(),
- 'subtotal' => $item->get_subtotal(),
- 'total' => $item->get_total(),
+ $data['line_items'][] = ExtensibleAbilities::add_fields_to_object(
+ array(
+ 'id' => $item->get_id(),
+ 'name' => $item->get_name(),
+ 'product_id' => $item->get_product_id(),
+ 'variation_id' => $item->get_variation_id(),
+ 'quantity' => $item->get_quantity(),
+ 'subtotal' => $item->get_subtotal(),
+ 'total' => $item->get_total(),
+ ),
+ 'order_item',
+ $item
);
}
}
- return $data;
+ return ExtensibleAbilities::add_fields_to_object( $data, 'order', $order );
}
}
diff --git a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
index 56338cfa5fc..462cb938eee 100644
--- a/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
+++ b/plugins/woocommerce/src/Internal/Abilities/Domain/Traits/ProductAbilityTrait.php
@@ -10,6 +10,7 @@ namespace Automattic\WooCommerce\Internal\Abilities\Domain\Traits;
use Automattic\WooCommerce\Enums\ProductStockStatus;
use Automattic\WooCommerce\Enums\ProductStatus;
use Automattic\WooCommerce\Enums\ProductType;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
defined( 'ABSPATH' ) || exit;
@@ -784,7 +785,7 @@ trait ProductAbilityTrait {
$stock_quantity = $product->get_stock_quantity();
$permalink = $product->get_permalink();
- return array(
+ $data = array(
'id' => $product->get_id(),
'name' => $product->get_name(),
'slug' => $product->get_slug(),
@@ -813,6 +814,8 @@ trait ProductAbilityTrait {
'date_modified' => wc_rest_prepare_date_response( $product->get_date_modified(), false ),
'date_modified_gmt' => wc_rest_prepare_date_response( $product->get_date_modified() ),
);
+
+ return ExtensibleAbilities::add_fields_to_object( $data, 'product', $product );
}
/**
@@ -821,87 +824,90 @@ trait ProductAbilityTrait {
* @return array
*/
protected static function get_product_output_schema(): array {
- return array(
- 'type' => 'object',
- 'properties' => array(
- 'id' => array( 'type' => 'integer' ),
- 'name' => array( 'type' => 'string' ),
- 'slug' => array( 'type' => 'string' ),
- 'permalink' => array(
- 'type' => array( 'string', 'null' ),
- 'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
- 'format' => 'uri',
- ),
- 'type' => array(
- 'type' => 'string',
- 'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
- 'enum' => array_keys( wc_get_product_types() ),
- ),
- 'status' => array(
- 'type' => 'string',
- 'enum' => self::get_product_output_status_slugs(),
- ),
- 'sku' => array( 'type' => 'string' ),
- 'currency' => array(
- 'type' => 'string',
- 'enum' => array_keys( get_woocommerce_currencies() ),
- ),
- 'currency_symbol' => array( 'type' => 'string' ),
- 'price' => array(
- 'type' => 'string',
- 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
- ),
- 'regular_price' => array(
- 'type' => 'string',
- 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
- ),
- 'sale_price' => array(
- 'type' => 'string',
- 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
- ),
- 'stock_status' => array(
- 'type' => 'string',
- 'enum' => array_keys( wc_get_product_stock_status_options() ),
- ),
- 'stock_quantity' => array(
- 'type' => array( self::get_product_stock_quantity_schema_type(), 'null' ),
- 'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
- ),
- 'manage_stock' => array( 'type' => 'boolean' ),
- 'virtual' => array( 'type' => 'boolean' ),
- 'downloadable' => array( 'type' => 'boolean' ),
- 'external_url' => array(
- 'type' => array( 'string', 'null' ),
- 'description' => __( 'External product URL for external products.', 'woocommerce' ),
- 'format' => 'uri',
- ),
- 'button_text' => array(
- 'type' => array( 'string', 'null' ),
- 'description' => __( 'Button text for external products.', 'woocommerce' ),
- ),
- 'grouped_products' => array(
- 'type' => 'array',
- 'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
- 'items' => array( 'type' => 'integer' ),
- ),
- 'date_created' => array(
- 'type' => array( 'string', 'null' ),
- 'format' => 'date-time',
- ),
- 'date_created_gmt' => array(
- 'type' => array( 'string', 'null' ),
- 'format' => 'date-time',
- ),
- 'date_modified' => array(
- 'type' => array( 'string', 'null' ),
- 'format' => 'date-time',
- ),
- 'date_modified_gmt' => array(
- 'type' => array( 'string', 'null' ),
- 'format' => 'date-time',
+ return ExtensibleAbilities::add_fields_schema(
+ array(
+ 'type' => 'object',
+ 'properties' => array(
+ 'id' => array( 'type' => 'integer' ),
+ 'name' => array( 'type' => 'string' ),
+ 'slug' => array( 'type' => 'string' ),
+ 'permalink' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'Product permalink, or null when no public permalink is available.', 'woocommerce' ),
+ 'format' => 'uri',
+ ),
+ 'type' => array(
+ 'type' => 'string',
+ 'description' => __( 'Internal product type slug, such as simple, external, grouped, or variable.', 'woocommerce' ),
+ 'enum' => array_keys( wc_get_product_types() ),
+ ),
+ 'status' => array(
+ 'type' => 'string',
+ 'enum' => self::get_product_output_status_slugs(),
+ ),
+ 'sku' => array( 'type' => 'string' ),
+ 'currency' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( get_woocommerce_currencies() ),
+ ),
+ 'currency_symbol' => array( 'type' => 'string' ),
+ 'price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'regular_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'sale_price' => array(
+ 'type' => 'string',
+ 'description' => __( 'Decimal price as a string, without a currency symbol.', 'woocommerce' ),
+ ),
+ 'stock_status' => array(
+ 'type' => 'string',
+ 'enum' => array_keys( wc_get_product_stock_status_options() ),
+ ),
+ 'stock_quantity' => array(
+ 'type' => array( self::get_product_stock_quantity_schema_type(), 'null' ),
+ 'description' => __( 'Current stock quantity, or null when no stock quantity is set.', 'woocommerce' ),
+ ),
+ 'manage_stock' => array( 'type' => 'boolean' ),
+ 'virtual' => array( 'type' => 'boolean' ),
+ 'downloadable' => array( 'type' => 'boolean' ),
+ 'external_url' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'External product URL for external products.', 'woocommerce' ),
+ 'format' => 'uri',
+ ),
+ 'button_text' => array(
+ 'type' => array( 'string', 'null' ),
+ 'description' => __( 'Button text for external products.', 'woocommerce' ),
+ ),
+ 'grouped_products' => array(
+ 'type' => 'array',
+ 'description' => __( 'Product IDs included as children of a grouped product.', 'woocommerce' ),
+ 'items' => array( 'type' => 'integer' ),
+ ),
+ 'date_created' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_created_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
+ 'date_modified_gmt' => array(
+ 'type' => array( 'string', 'null' ),
+ 'format' => 'date-time',
+ ),
),
+ 'additionalProperties' => false,
),
- 'additionalProperties' => false,
+ 'product'
);
}
diff --git a/plugins/woocommerce/src/Internal/Features/FeaturesController.php b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
index 66ab0b744c5..88c0f0eebfb 100644
--- a/plugins/woocommerce/src/Internal/Features/FeaturesController.php
+++ b/plugins/woocommerce/src/Internal/Features/FeaturesController.php
@@ -7,6 +7,7 @@ declare( strict_types=1 );
namespace Automattic\WooCommerce\Internal\Features;
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
use Automattic\WooCommerce\Internal\Admin\EmailPreview\EmailPreview;
use WC_Tracks;
use WC_Site_Tracking;
@@ -755,6 +756,15 @@ class FeaturesController {
'is_experimental' => true,
'disable_ui' => false,
),
+ ExtensibleAbilities::FEATURE_ID => array(
+ 'name' => __( 'Extensible abilities', 'woocommerce' ),
+ 'description' => __( 'Let extensions add their fields to the output of WooCommerce abilities.', 'woocommerce' ),
+ 'is_experimental' => true,
+ 'enabled_by_default' => false,
+ 'disable_ui' => false,
+ 'skip_compatibility_checks' => true,
+ 'default_plugin_compatibility' => FeaturePluginCompatibility::COMPATIBLE,
+ ),
);
if ( ! $tracking_enabled ) {
diff --git a/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
new file mode 100644
index 00000000000..6a897028afa
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Internal/AbilitiesApi/ExtensibleAbilitiesTest.php
@@ -0,0 +1,638 @@
+<?php
+/**
+ * ExtensibleAbilitiesTest class file.
+ */
+
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Internal\AbilitiesApi;
+
+use Automattic\WooCommerce\Abilities\ExtensibleAbilities;
+use Automattic\WooCommerce\Internal\Abilities\AbilitiesLoader;
+
+/**
+ * Namespaced extension fields in the output of the product and order abilities, read through the REST route.
+ */
+class ExtensibleAbilitiesTest extends \WC_REST_Unit_Test_Case {
+
+ private const CODE_SCHEMA = array(
+ 'type' => 'string',
+ 'title' => 'Test code',
+ );
+
+ /**
+ * Shared administrator used as the current user.
+ *
+ * @var int
+ */
+ private static $administrator_id;
+
+ /**
+ * Original init action count restored in tearDown.
+ *
+ * @var int|null
+ */
+ private $original_init_count;
+
+ /**
+ * Create immutable class fixtures.
+ *
+ * @param \WP_UnitTest_Factory $factory WordPress unit test factory.
+ */
+ public static function wpSetUpBeforeClass( $factory ): void {
+ self::$administrator_id = $factory->user->create( array( 'role' => 'administrator' ) );
+ }
+
+ /**
+ * Set up test fixtures.
+ */
+ public function setUp(): void {
+ global $wp_actions;
+
+ if ( ! function_exists( 'wp_register_ability' ) ) {
+ require_once WC_ABSPATH . 'vendor/wordpress/abilities-api/includes/bootstrap.php';
+ }
+
+ $this->original_init_count = $wp_actions['init'] ?? null;
+ $wp_actions['init'] = max( 1, (int) ( $wp_actions['init'] ?? 0 ) ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+
+ parent::setUp();
+
+ if ( class_exists( 'WP_REST_Abilities_Init' ) && ! isset( $this->server->get_routes()['/wp-abilities/v1/abilities'] ) ) {
+ \WP_REST_Abilities_Init::register_routes( $this->server );
+ }
+
+ AbilitiesLoader::init();
+ $this->reset_registries();
+ wp_set_current_user( self::$administrator_id );
+ $this->set_feature( true );
+ add_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+ }
+
+ /**
+ * Tear down test fixtures.
+ */
+ public function tearDown(): void {
+ global $wp_actions;
+
+ remove_action( 'woocommerce_extensible_abilities_init', array( $this, 'register_test_fields' ) );
+ $this->set_feature( false );
+ $this->reset_registries();
+ wp_set_current_user( 0 );
+
+ parent::tearDown();
+
+ if ( null !== $this->original_init_count ) {
+ $wp_actions['init'] = $this->original_init_count; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ } else {
+ unset( $wp_actions['init'] ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
+ }
+ }
+
+ /**
+ * Register the fields of two extensions, the way an extension does inside woocommerce_extensible_abilities_init.
+ *
+ * @internal
+ */
+ public function register_test_fields(): void {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'field' => 'code',
+ 'schema' => self::CODE_SCHEMA,
+ 'get_callback' => static function ( \WC_Product $product ) {
+ $code = $product->get_meta( '_test_code' );
+ return '' === $code ? null : $code;
+ },
+ )
+ );
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'other_ext',
+ 'field' => 'code',
+ 'schema' => array( 'type' => 'integer' ),
+ 'get_callback' => static function ( \WC_Product $product ) {
+ return '' === $product->get_meta( '_test_code' ) ? null : 7;
+ },
+ )
+ );
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'field' => 'note',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static function ( \WC_Order $order ) {
+ return 'order-' . $order->get_id();
+ },
+ )
+ );
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'order_item',
+ 'namespace' => 'test-ext',
+ 'field' => 'gift',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static function ( \WC_Order_Item $item ) {
+ return 'item-' . $item->get_id();
+ },
+ )
+ );
+ }
+
+ /**
+ * @testdox Should return the fields of each extension under its namespace, and leave out a product with no values.
+ */
+ public function test_product_reads_return_fields_under_their_namespace(): void {
+ $with_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen with code' ) );
+ $with_code->update_meta_data( '_test_code', 'A1' );
+ $with_code->save();
+ $without_code = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen without code' ) );
+ $expected = array(
+ 'test-ext' => array( 'code' => 'A1' ),
+ 'other_ext' => array( 'code' => 7 ),
+ );
+
+ $products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'], null, 'id' );
+ $updated = $this->run_ability(
+ 'woocommerce/product-update',
+ array(
+ 'id' => $with_code->get_id(),
+ 'name' => 'Pencil',
+ )
+ );
+
+ $this->assertSame( $expected, $products[ $with_code->get_id() ]['extensions'] );
+ $this->assertArrayNotHasKey( 'extensions', $products[ $without_code->get_id() ] );
+ $this->assertSame( $expected, $updated['product']['extensions'] );
+ }
+
+ /**
+ * @testdox Should return order and line item fields under their namespace.
+ */
+ public function test_order_reads_return_fields_under_their_namespace(): void {
+ $order = \WC_Helper_Order::create_order();
+ $item = current( $order->get_items() );
+
+ $listed = $this->run_ability(
+ 'woocommerce/orders-query',
+ array(
+ 'per_page' => 1,
+ 'include_line_items' => true,
+ )
+ )['orders'][0];
+ $noted = $this->run_ability(
+ 'woocommerce/order-add-note',
+ array(
+ 'id' => $order->get_id(),
+ 'note' => 'Hi',
+ )
+ );
+
+ $this->assertSame( array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ), $listed['extensions'] );
+ $this->assertSame( array( 'test-ext' => array( 'gift' => 'item-' . $item->get_id() ) ), $listed['line_items'][0]['extensions'] );
+ $this->assertSame( array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ), $noted['order']['extensions'] );
+ }
+
+ /**
+ * @testdox Should list the fields of each namespace in the output schemas, and add nothing for a resource with no fields.
+ */
+ public function test_output_schemas_list_fields_by_namespace(): void {
+ $products = $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties']['extensions'];
+ $product = $this->get_output_schema( 'woocommerce/product-update' )['properties']['product']['properties']['extensions'];
+ $order = $this->get_output_schema( 'woocommerce/orders-query' )['properties']['orders']['items']['properties'];
+
+ $this->assertSame( self::CODE_SCHEMA, $products['properties']['test-ext']['properties']['code'] );
+ $this->assertSame( array( 'type' => 'integer' ), $products['properties']['other_ext']['properties']['code'] );
+ $this->assertSame( self::CODE_SCHEMA, $product['properties']['test-ext']['properties']['code'] );
+ $this->assertSame( array( 'type' => 'string' ), $order['extensions']['properties']['test-ext']['properties']['note'] );
+ $this->assertSame( array( 'type' => 'string' ), $order['line_items']['items']['properties']['extensions']['properties']['test-ext']['properties']['gift'] );
+ $this->assertSame( array( 'type' => 'object' ), ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'customer' ) );
+ $this->assertFalse( $products['additionalProperties'] );
+ $this->assertFalse( $products['properties']['test-ext']['additionalProperties'] );
+ }
+
+ /**
+ * @testdox Should fire woocommerce_extensible_abilities_init one time, before the abilities build their schemas.
+ */
+ public function test_init_action_fires_once_before_the_schemas(): void {
+ $events = array();
+ $on_init = static function () use ( &$events ) {
+ $events[] = 'extensions';
+ };
+ $on_ability = static function ( $args, $name ) use ( &$events ) {
+ $events[] = $name;
+ return $args;
+ };
+ add_action( 'woocommerce_extensible_abilities_init', $on_init );
+ add_filter( 'wp_register_ability_args', $on_ability, 10, 2 );
+
+ $this->get_output_schema( 'woocommerce/products-query' );
+ do_action( 'wp_abilities_api_init' );
+
+ remove_action( 'woocommerce_extensible_abilities_init', $on_init );
+ remove_filter( 'wp_register_ability_args', $on_ability, 10 );
+ $this->assertSame( 1, did_action( 'woocommerce_extensible_abilities_init' ) );
+ $this->assertSame( 'extensions', $events[0] );
+ $this->assertSame( 1, count( array_keys( $events, 'extensions', true ) ) );
+ $this->assertContains( 'woocommerce/products-query', $events );
+ }
+
+ /**
+ * @testdox Should refuse a field that is registered after the init action, and keep the abilities valid.
+ */
+ public function test_a_field_registered_after_the_init_action_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $this->get_output_schema( 'woocommerce/products-query' );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen late' ) );
+
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'late-ext',
+ 'field' => 'code',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static fn() => 'late',
+ )
+ );
+ $products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen late' ) )['products'], null, 'id' );
+
+ $this->assertArrayNotHasKey( 'late-ext', $products[ $product->get_id() ]['extensions'] ?? array() );
+ $this->assertArrayNotHasKey( 'late-ext', ExtensibleAbilities::add_fields_to_object( array(), 'product', $product )['extensions'] ?? array() );
+ }
+
+ /**
+ * @testdox Should let an extension register fields on a resource that another extension registers, whatever the hook priorities.
+ */
+ public function test_register_resource_lets_an_extension_register_fields_on_its_own_resource(): void {
+ $register_resource = static function () {
+ ExtensibleAbilities::register_resource( 'ticket' );
+ };
+ $register_field = static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'ticket',
+ 'namespace' => 'test-ext',
+ 'field' => 'code',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static fn() => 'T1',
+ )
+ );
+ };
+ add_action( 'woocommerce_extensible_abilities_resources_init', $register_resource, 99 );
+ add_action( 'woocommerce_extensible_abilities_init', $register_field, 1 );
+ $this->get_output_schema( 'woocommerce/products-query' );
+ remove_action( 'woocommerce_extensible_abilities_resources_init', $register_resource, 99 );
+ remove_action( 'woocommerce_extensible_abilities_init', $register_field, 1 );
+
+ $output = ExtensibleAbilities::add_fields_to_object( array(), 'ticket', new \stdClass() );
+ $schema = ExtensibleAbilities::add_fields_schema( array( 'type' => 'object' ), 'ticket' );
+
+ $this->assertSame( array( 'test-ext' => array( 'code' => 'T1' ) ), $output['extensions'] );
+ $this->assertSame( array( 'type' => 'string' ), $schema['properties']['extensions']['properties']['test-ext']['properties']['code'] );
+ }
+
+ /**
+ * @testdox Should refuse a resource that is registered after the resources action.
+ */
+ public function test_a_resource_registered_after_the_resources_action_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_resource' );
+ $this->get_output_schema( 'woocommerce/products-query' );
+
+ ExtensibleAbilities::register_resource( 'late_resource' );
+
+ $resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+ $resources->setAccessible( true );
+ $this->assertSame( array( 'product', 'order', 'order_item' ), array_keys( $resources->getValue() ) );
+ }
+
+ /**
+ * @testdox Should refuse a resource name made only of digits.
+ */
+ public function test_a_resource_name_of_only_digits_is_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_resource' );
+
+ ExtensibleAbilities::register_resource( '42' );
+
+ $resources = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+ $resources->setAccessible( true );
+ $this->assertSame( array(), $resources->getValue() );
+ }
+
+ /**
+ * @testdox Should tell an extension to use the init action when it registers a field before the resources exist.
+ */
+ public function test_a_field_registered_before_the_resources_points_to_the_init_action(): void {
+ $messages = array();
+ $capture = static function ( $function_name, $message ) use ( &$messages ) {
+ $messages[] = $message;
+ };
+ add_action( 'doing_it_wrong_run', $capture, 10, 2 );
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'field' => 'code',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static fn() => 'T1',
+ )
+ );
+ remove_action( 'doing_it_wrong_run', $capture, 10 );
+
+ $this->assertStringContainsString( 'inside the woocommerce_extensible_abilities_init action', $messages[0] ?? '' );
+ }
+
+ /**
+ * @testdox Should not fire woocommerce_extensible_abilities_init or change the abilities with the feature off.
+ */
+ public function test_nothing_changes_with_the_feature_off(): void {
+ $this->set_feature( false );
+ $product = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen' ) );
+ $product->update_meta_data( '_test_code', 'A1' );
+ $product->save();
+ \WC_Helper_Order::create_order();
+
+ $products = $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'];
+ $orders = $this->run_ability(
+ 'woocommerce/orders-query',
+ array(
+ 'per_page' => 1,
+ 'include_line_items' => true,
+ )
+ )['orders'];
+
+ $this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_resources_init' ) );
+ $this->assertSame( 0, did_action( 'woocommerce_extensible_abilities_init' ) );
+ $this->assertArrayNotHasKey( 'extensions', $this->get_output_schema( 'woocommerce/products-query' )['properties']['products']['items']['properties'] );
+ $this->assertArrayNotHasKey( 'extensions', $products[0] );
+ $this->assertArrayNotHasKey( 'extensions', $orders[0] );
+ $this->assertArrayNotHasKey( 'extensions', $orders[0]['line_items'][0] );
+ }
+
+ /**
+ * @testdox Should let an ability that is not Core's return the registered fields.
+ */
+ public function test_an_ability_that_is_not_cores_returns_the_fields(): void {
+ $product = \WC_Helper_Product::create_simple_product();
+ $product->update_meta_data( '_test_code', 'X9' );
+ $product->save();
+ $register = static function () {
+ wp_register_ability(
+ 'test-ext/product-read',
+ array(
+ 'label' => 'Read product',
+ 'description' => 'Reads a product.',
+ 'category' => 'woocommerce',
+ 'input_schema' => array(
+ 'type' => 'object',
+ 'properties' => array( 'id' => array( 'type' => 'integer' ) ),
+ ),
+ 'output_schema' => ExtensibleAbilities::add_fields_schema(
+ array(
+ 'type' => 'object',
+ 'properties' => array( 'id' => array( 'type' => 'integer' ) ),
+ ),
+ 'product'
+ ),
+ 'execute_callback' => static function ( array $input ) {
+ $product = wc_get_product( $input['id'] );
+ return ExtensibleAbilities::add_fields_to_object( array( 'id' => $product->get_id() ), 'product', $product );
+ },
+ 'permission_callback' => '__return_true',
+ 'meta' => array(
+ 'show_in_rest' => true,
+ 'annotations' => array( 'readonly' => true ),
+ ),
+ )
+ );
+ };
+ add_action( 'wp_abilities_api_init', $register, 20 );
+
+ $output = $this->run_ability( 'test-ext/product-read', array( 'id' => $product->get_id() ) );
+
+ remove_action( 'wp_abilities_api_init', $register, 20 );
+ $this->assertSame( array( 'code' => 'X9' ), $output['extensions']['test-ext'] );
+ }
+
+ /**
+ * @testdox Should refuse a field with invalid arguments, and keep the first registration of a namespace and field.
+ */
+ public function test_invalid_and_duplicate_fields_are_refused(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ $valid = array(
+ 'resource' => 'order',
+ 'namespace' => 'test-ext',
+ 'field' => 'note',
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static fn() => 'second',
+ );
+ AbilitiesLoader::register_core_resources();
+ $this->register_test_fields();
+
+ ExtensibleAbilities::register_field( $valid );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => 'Test Ext' ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '' ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'schema' => null ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'get_callback' => 'not_a_function' ) ) );
+ ExtensibleAbilities::register_field(
+ array_merge(
+ $valid,
+ array(
+ 'field' => 'other',
+ 'resource' => 'line_item',
+ )
+ )
+ );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => 'other' ), array( 'schema' => array( 'description' => 'x' ) ) ) );
+ ExtensibleAbilities::register_field(
+ array_merge(
+ $valid,
+ array( 'field' => 'other' ),
+ array(
+ 'schema' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ )
+ )
+ );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'namespace' => '0' ) ) );
+ ExtensibleAbilities::register_field( array_merge( $valid, array( 'field' => '123' ) ) );
+ $order = \WC_Helper_Order::create_order();
+
+ $this->assertSame(
+ array( 'test-ext' => array( 'note' => 'order-' . $order->get_id() ) ),
+ ExtensibleAbilities::add_fields_to_object( array(), 'order', $order )['extensions']
+ );
+ }
+
+ /**
+ * @testdox Should leave out a field that throws or fails sanitization, cast a value to its schema type, and keep a null that the schema allows.
+ */
+ public function test_field_values_follow_their_schema(): void {
+ AbilitiesLoader::register_core_resources();
+ $this->register_test_fields();
+ $fields = array(
+ 'broken' => array(
+ 'schema' => array( 'type' => 'string' ),
+ 'get_callback' => static function () {
+ throw new \RuntimeException( 'Broken' );
+ },
+ ),
+ 'count' => array(
+ 'schema' => array( 'type' => 'integer' ),
+ 'get_callback' => static fn() => '12',
+ ),
+ 'empty' => array(
+ 'schema' => array( 'type' => array( 'string', 'null' ) ),
+ 'get_callback' => '__return_null',
+ ),
+ 'dupes' => array(
+ 'schema' => array(
+ 'type' => 'array',
+ 'items' => array( 'type' => 'integer' ),
+ 'uniqueItems' => true,
+ ),
+ 'get_callback' => static fn() => array( '12', 12 ),
+ ),
+ );
+ foreach ( $fields as $field => $args ) {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'order',
+ 'namespace' => 'values',
+ 'field' => $field,
+ ) + $args
+ );
+ }
+ $order = \WC_Helper_Order::create_order();
+
+ $output = ExtensibleAbilities::add_fields_to_object( array(), 'order', $order );
+
+ $this->assertSame(
+ array(
+ 'count' => 12,
+ 'empty' => null,
+ ),
+ $output['extensions']['values']
+ );
+ }
+
+ /**
+ * @testdox Should leave out and report one time a value that does not match its schema, and keep the output valid.
+ */
+ public function test_a_value_that_does_not_match_its_schema_is_left_out(): void {
+ $this->setExpectedIncorrectUsage( ExtensibleAbilities::class . '::register_field' );
+ add_action(
+ 'woocommerce_extensible_abilities_init',
+ static function () {
+ ExtensibleAbilities::register_field(
+ array(
+ 'resource' => 'product',
+ 'namespace' => 'test-ext',
+ 'field' => 'count',
+ 'schema' => array( 'type' => 'integer' ),
+ 'get_callback' => static fn() => 'many',
+ )
+ );
+ }
+ );
+ $reported = array();
+ add_action(
+ 'doing_it_wrong_run',
+ static function ( $function_name, $message ) use ( &$reported ) {
+ $reported[] = $message;
+ },
+ 10,
+ 2
+ );
+ $first = \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen one' ) );
+ $first->update_meta_data( '_test_code', 'A1' );
+ $first->save();
+ \WC_Helper_Product::create_simple_product( true, array( 'name' => 'Pen two' ) );
+
+ $products = array_column( $this->run_ability( 'woocommerce/products-query', array( 'search' => 'Pen' ) )['products'], null, 'id' );
+
+ $this->assertSame( array( 'code' => 'A1' ), $products[ $first->get_id() ]['extensions']['test-ext'] );
+ $matches = array_filter(
+ $reported,
+ static fn( $message ) => false !== strpos( $message, '"test-ext.count" of "product"' )
+ );
+ $this->assertCount( 1, $matches );
+ }
+
+ /**
+ * Run an ability through the REST route and return its output.
+ *
+ * @param string $name Ability name.
+ * @param array $input Ability input.
+ * @return array
+ */
+ private function run_ability( string $name, array $input ): array {
+ $readonly = ! empty( wp_get_ability( $name )->get_meta()['annotations']['readonly'] );
+ $request = new \WP_REST_Request( $readonly ? 'GET' : 'POST', '/wp-abilities/v1/abilities/' . $name . '/run' );
+ if ( $readonly ) {
+ $request->set_query_params( array( 'input' => $input ) );
+ } else {
+ $request->set_header( 'Content-Type', 'application/json' );
+ $request->set_body( wp_json_encode( array( 'input' => $input ) ) );
+ }
+
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ return $response->get_data();
+ }
+
+ /**
+ * Read an ability's output schema through the REST route.
+ *
+ * @param string $name Ability name.
+ * @return array
+ */
+ private function get_output_schema( string $name ): array {
+ $response = $this->server->dispatch( new \WP_REST_Request( 'GET', '/wp-abilities/v1/abilities/' . $name ) );
+
+ $this->assertSame( 200, $response->get_status(), wp_json_encode( $response->get_data() ) );
+ return $response->get_data()['output_schema'];
+ }
+
+ /**
+ * Turn the feature on or off.
+ *
+ * @param bool $enabled Whether the feature is on.
+ */
+ private function set_feature( bool $enabled ): void {
+ update_option( 'woocommerce_feature_' . ExtensibleAbilities::FEATURE_ID . '_enabled', $enabled ? 'yes' : 'no' );
+ }
+
+ /**
+ * Drop every registered field and ability, so the next read fires the init actions again.
+ */
+ private function reset_registries(): void {
+ global $wp_actions;
+
+ foreach ( array( 'fields', 'reported' ) as $property ) {
+ $reflection = new \ReflectionProperty( ExtensibleAbilities::class, $property );
+ $reflection->setAccessible( true );
+ $reflection->setValue( null, array() );
+ }
+
+ $reflection = new \ReflectionProperty( ExtensibleAbilities::class, 'resources' );
+ $reflection->setAccessible( true );
+ $reflection->setValue( null, array() );
+
+ foreach ( array( 'WP_Abilities_Registry', 'WP_Ability_Categories_Registry' ) as $registry ) {
+ if ( class_exists( $registry ) ) {
+ $instance = new \ReflectionProperty( $registry, 'instance' );
+ $instance->setAccessible( true );
+ $instance->setValue( null, null );
+ }
+ }
+ unset( $wp_actions['wp_abilities_api_init'], $wp_actions['wp_abilities_api_categories_init'], $wp_actions['woocommerce_extensible_abilities_resources_init'], $wp_actions['woocommerce_extensible_abilities_init'] );
+ }
+}