From 5a4d0da0ed466d0a08b931b9256ca503cecca3f6 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Fri, 10 Oct 2025 23:18:50 -0400 Subject: [PATCH 1/7] feat: Add to_array() and to_json_schema() methods to WP_Ability - Add WP_Ability::to_array() method for declarative array export - Add WP_Ability::to_json_schema() method for JSON Schema generation - Add comprehensive test coverage for both methods - Ensure annotations and show_in_rest remain under meta structure The to_array() method returns a complete array representation of the ability including name, label, description, schemas, and metadata, excluding callbacks as they are not serializable. The to_json_schema() method generates a JSON Schema Draft 7 compliant schema describing the ability's structure, useful for validation in both PHP and JavaScript contexts. Implements feedback from PR #108. --- includes/abilities-api/class-wp-ability.php | 97 +++++++++ tests/unit/abilities-api/wpAbility.php | 207 ++++++++++++++++++++ 2 files changed, 304 insertions(+) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 280b7fcc..1053c211 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -340,6 +340,103 @@ public function get_meta_item( string $key, $default_value = null ) { return array_key_exists( $key, $this->meta ) ? $this->meta[ $key ] : $default_value; } + /** + * Converts the ability to an array representation. + * + * Returns a complete array representation of the ability including name, label, + * description, schemas, and metadata. Callbacks are excluded as they are not serializable. + * + * @since n.e.x.t + * + * @return array { + * The ability as an associative array. + * + * @type string $name The ability name with namespace. + * @type string $label The human-readable label. + * @type string $description The detailed description. + * @type array $input_schema The input validation schema. + * @type array $output_schema The output validation schema. + * @type array $meta { + * Metadata for the ability. + * + * @type array $annotations { + * Behavior annotations. + * + * @type string $instructions Usage instructions. + * @type bool $readonly Whether the ability is read-only. + * @type bool $destructive Whether the ability is destructive. + * @type bool $idempotent Whether the ability is idempotent. + * } + * @type bool $show_in_rest Whether the ability is exposed in REST API. + * } + * } + */ + public function to_array(): array { + return array( + 'name' => $this->name, + 'label' => $this->label, + 'description' => $this->description, + 'input_schema' => $this->input_schema, + 'output_schema' => $this->output_schema, + 'meta' => $this->meta, + ); + } + + /** + * Converts the ability to a JSON Schema representation. + * + * Generates a JSON Schema Draft 7 compliant schema describing the ability's + * structure, including input/output schemas and metadata. + * + * @since n.e.x.t + * + * @return array A JSON Schema representation of the ability. + */ + public function to_json_schema(): array { + $schema = array( + '$schema' => 'http://json-schema.org/draft-07/schema#', + 'type' => 'object', + 'title' => $this->label, + 'description' => $this->description, + 'properties' => array( + 'name' => array( + 'type' => 'string', + 'const' => $this->name, + ), + 'meta' => array( + 'type' => 'object', + 'properties' => array( + 'annotations' => array( + 'type' => 'object', + 'properties' => array( + 'instructions' => array( 'type' => 'string' ), + 'readonly' => array( 'type' => 'boolean' ), + 'destructive' => array( 'type' => 'boolean' ), + 'idempotent' => array( 'type' => 'boolean' ), + ), + ), + 'show_in_rest' => array( + 'type' => 'boolean', + ), + ), + ), + ), + 'required' => array( 'name', 'meta' ), + ); + + if ( ! empty( $this->input_schema ) ) { + $schema['properties']['input_schema'] = $this->input_schema; + $schema['required'][] = 'input_schema'; + } + + if ( ! empty( $this->output_schema ) ) { + $schema['properties']['output_schema'] = $this->output_schema; + $schema['required'][] = 'output_schema'; + } + + return $schema; + } + /** * Validates input data against the input schema. * diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 7c861520..8182a10f 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -702,4 +702,211 @@ public function test_after_action_not_fired_on_output_validation_error() { $this->assertFalse( $after_action_fired, 'after_execute_ability action should not be fired when output validation fails' ); $this->assertInstanceOf( WP_Error::class, $result, 'Should return WP_Error for output validation failure' ); } + + /** + * Tests that to_array() returns correct structure with all expected keys. + */ + public function test_to_array_returns_correct_structure() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $array = $ability->to_array(); + + $this->assertIsArray( $array, 'to_array() should return an array' ); + $this->assertArrayHasKey( 'name', $array, 'Array should contain name key' ); + $this->assertArrayHasKey( 'label', $array, 'Array should contain label key' ); + $this->assertArrayHasKey( 'description', $array, 'Array should contain description key' ); + $this->assertArrayHasKey( 'input_schema', $array, 'Array should contain input_schema key' ); + $this->assertArrayHasKey( 'output_schema', $array, 'Array should contain output_schema key' ); + $this->assertArrayHasKey( 'meta', $array, 'Array should contain meta key' ); + + $this->assertSame( self::$test_ability_name, $array['name'], 'Name should match' ); + $this->assertSame( 'Calculator', $array['label'], 'Label should match' ); + $this->assertSame( 'Calculates the result of math operations.', $array['description'], 'Description should match' ); + } + + /** + * Tests that to_array() does not include callbacks. + */ + public function test_to_array_excludes_callbacks() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $array = $ability->to_array(); + + $this->assertArrayNotHasKey( 'execute_callback', $array, 'Array should not contain execute_callback' ); + $this->assertArrayNotHasKey( 'permission_callback', $array, 'Array should not contain permission_callback' ); + } + + /** + * Tests to_array() with both input and output schemas. + */ + public function test_to_array_with_full_schemas() { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input.', + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $array = $ability->to_array(); + + $this->assertIsArray( $array['input_schema'], 'Input schema should be an array' ); + $this->assertSame( 'integer', $array['input_schema']['type'], 'Input schema type should match' ); + $this->assertIsArray( $array['output_schema'], 'Output schema should be an array' ); + $this->assertSame( 'number', $array['output_schema']['type'], 'Output schema type should match' ); + } + + /** + * Tests to_array() without input schema. + */ + public function test_to_array_without_input_schema() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $array = $ability->to_array(); + + $this->assertArrayHasKey( 'input_schema', $array, 'input_schema key should exist' ); + $this->assertIsArray( $array['input_schema'], 'input_schema should be an array' ); + $this->assertEmpty( $array['input_schema'], 'input_schema should be empty' ); + } + + /** + * Tests to_array() meta structure. + */ + public function test_to_array_meta_structure() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $array = $ability->to_array(); + + $this->assertIsArray( $array['meta'], 'Meta should be an array' ); + $this->assertArrayHasKey( 'annotations', $array['meta'], 'Meta should contain annotations' ); + $this->assertArrayHasKey( 'show_in_rest', $array['meta'], 'Meta should contain show_in_rest' ); + + $this->assertIsArray( $array['meta']['annotations'], 'Annotations should be an array' ); + $this->assertArrayHasKey( 'readonly', $array['meta']['annotations'], 'Annotations should contain readonly' ); + $this->assertArrayHasKey( 'destructive', $array['meta']['annotations'], 'Annotations should contain destructive' ); + $this->assertArrayHasKey( 'idempotent', $array['meta']['annotations'], 'Annotations should contain idempotent' ); + $this->assertArrayHasKey( 'instructions', $array['meta']['annotations'], 'Annotations should contain instructions' ); + + $this->assertTrue( $array['meta']['annotations']['readonly'], 'Readonly should be true' ); + $this->assertFalse( $array['meta']['annotations']['destructive'], 'Destructive should be false' ); + $this->assertFalse( $array['meta']['show_in_rest'], 'show_in_rest should default to false' ); + } + + /** + * Tests to_array() with custom meta properties. + */ + public function test_to_array_with_custom_meta() { + $args = array_merge( + self::$test_ability_properties, + array( + 'meta' => array( + 'custom_property' => 'custom_value', + 'another_prop' => 123, + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $array = $ability->to_array(); + + $this->assertArrayHasKey( 'custom_property', $array['meta'], 'Custom meta property should be included' ); + $this->assertSame( 'custom_value', $array['meta']['custom_property'], 'Custom meta value should match' ); + $this->assertArrayHasKey( 'another_prop', $array['meta'], 'Another custom meta property should be included' ); + $this->assertSame( 123, $array['meta']['another_prop'], 'Another custom meta value should match' ); + } + + /** + * Tests that to_json_schema() returns valid JSON Schema structure. + */ + public function test_to_json_schema_returns_valid_structure() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $schema = $ability->to_json_schema(); + + $this->assertIsArray( $schema, 'to_json_schema() should return an array' ); + $this->assertArrayHasKey( '$schema', $schema, 'Schema should contain $schema key' ); + $this->assertSame( 'http://json-schema.org/draft-07/schema#', $schema['$schema'], 'Schema version should be Draft 7' ); + $this->assertArrayHasKey( 'type', $schema, 'Schema should contain type key' ); + $this->assertSame( 'object', $schema['type'], 'Schema type should be object' ); + $this->assertArrayHasKey( 'title', $schema, 'Schema should contain title key' ); + $this->assertSame( 'Calculator', $schema['title'], 'Schema title should match label' ); + $this->assertArrayHasKey( 'description', $schema, 'Schema should contain description key' ); + $this->assertSame( 'Calculates the result of math operations.', $schema['description'], 'Schema description should match' ); + } + + /** + * Tests to_json_schema() with both input and output schemas. + */ + public function test_to_json_schema_with_both_schemas() { + $args = array_merge( + self::$test_ability_properties, + array( + 'input_schema' => array( + 'type' => 'integer', + 'description' => 'Test input.', + ), + ) + ); + + $ability = new WP_Ability( self::$test_ability_name, $args ); + $schema = $ability->to_json_schema(); + + $this->assertArrayHasKey( 'properties', $schema, 'Schema should contain properties' ); + $this->assertArrayHasKey( 'input_schema', $schema['properties'], 'Properties should contain input_schema' ); + $this->assertArrayHasKey( 'output_schema', $schema['properties'], 'Properties should contain output_schema' ); + + $this->assertSame( 'integer', $schema['properties']['input_schema']['type'], 'Input schema should be preserved' ); + $this->assertSame( 'number', $schema['properties']['output_schema']['type'], 'Output schema should be preserved' ); + + $this->assertContains( 'input_schema', $schema['required'], 'input_schema should be in required array' ); + $this->assertContains( 'output_schema', $schema['required'], 'output_schema should be in required array' ); + } + + /** + * Tests to_json_schema() without input schema. + */ + public function test_to_json_schema_without_input_schema() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $schema = $ability->to_json_schema(); + + $this->assertArrayNotHasKey( 'input_schema', $schema['properties'], 'Properties should not contain input_schema when not defined' ); + $this->assertNotContains( 'input_schema', $schema['required'], 'input_schema should not be in required array when not defined' ); + $this->assertArrayHasKey( 'output_schema', $schema['properties'], 'Properties should contain output_schema' ); + $this->assertContains( 'output_schema', $schema['required'], 'output_schema should be in required array' ); + } + + /** + * Tests to_json_schema() meta structure. + */ + public function test_to_json_schema_meta_structure() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $schema = $ability->to_json_schema(); + + $this->assertArrayHasKey( 'meta', $schema['properties'], 'Properties should contain meta' ); + $this->assertSame( 'object', $schema['properties']['meta']['type'], 'Meta type should be object' ); + $this->assertArrayHasKey( 'properties', $schema['properties']['meta'], 'Meta should have properties' ); + $this->assertArrayHasKey( 'annotations', $schema['properties']['meta']['properties'], 'Meta properties should contain annotations' ); + $this->assertArrayHasKey( 'show_in_rest', $schema['properties']['meta']['properties'], 'Meta properties should contain show_in_rest' ); + + $annotations = $schema['properties']['meta']['properties']['annotations']; + $this->assertSame( 'object', $annotations['type'], 'Annotations type should be object' ); + $this->assertArrayHasKey( 'properties', $annotations, 'Annotations should have properties' ); + $this->assertArrayHasKey( 'readonly', $annotations['properties'], 'Annotations properties should contain readonly' ); + $this->assertArrayHasKey( 'destructive', $annotations['properties'], 'Annotations properties should contain destructive' ); + $this->assertArrayHasKey( 'idempotent', $annotations['properties'], 'Annotations properties should contain idempotent' ); + $this->assertArrayHasKey( 'instructions', $annotations['properties'], 'Annotations properties should contain instructions' ); + + $this->assertSame( 'boolean', $schema['properties']['meta']['properties']['show_in_rest']['type'], 'show_in_rest type should be boolean' ); + } + + /** + * Tests to_json_schema() name property uses const. + */ + public function test_to_json_schema_name_is_constant() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $schema = $ability->to_json_schema(); + + $this->assertArrayHasKey( 'name', $schema['properties'], 'Properties should contain name' ); + $this->assertArrayHasKey( 'const', $schema['properties']['name'], 'Name should have const keyword' ); + $this->assertSame( self::$test_ability_name, $schema['properties']['name']['const'], 'Name const should match ability name' ); + $this->assertContains( 'name', $schema['required'], 'name should be in required array' ); + } } From 1b11c6098f2e6459d7e3e3d185d80ba1dad1a480 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Fri, 10 Oct 2025 23:24:03 -0400 Subject: [PATCH 2/7] refactor: Use public getter methods instead of direct property access Replace direct property access with public getter methods in to_array() and to_json_schema() methods to follow encapsulation best practices. --- includes/abilities-api/class-wp-ability.php | 29 ++++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 1053c211..fb124405 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -373,12 +373,12 @@ public function get_meta_item( string $key, $default_value = null ) { */ public function to_array(): array { return array( - 'name' => $this->name, - 'label' => $this->label, - 'description' => $this->description, - 'input_schema' => $this->input_schema, - 'output_schema' => $this->output_schema, - 'meta' => $this->meta, + 'name' => $this->get_name(), + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'input_schema' => $this->get_input_schema(), + 'output_schema' => $this->get_output_schema(), + 'meta' => $this->get_meta(), ); } @@ -393,15 +393,18 @@ public function to_array(): array { * @return array A JSON Schema representation of the ability. */ public function to_json_schema(): array { + $input_schema = $this->get_input_schema(); + $output_schema = $this->get_output_schema(); + $schema = array( '$schema' => 'http://json-schema.org/draft-07/schema#', 'type' => 'object', - 'title' => $this->label, - 'description' => $this->description, + 'title' => $this->get_label(), + 'description' => $this->get_description(), 'properties' => array( 'name' => array( 'type' => 'string', - 'const' => $this->name, + 'const' => $this->get_name(), ), 'meta' => array( 'type' => 'object', @@ -424,13 +427,13 @@ public function to_json_schema(): array { 'required' => array( 'name', 'meta' ), ); - if ( ! empty( $this->input_schema ) ) { - $schema['properties']['input_schema'] = $this->input_schema; + if ( ! empty( $input_schema ) ) { + $schema['properties']['input_schema'] = $input_schema; $schema['required'][] = 'input_schema'; } - if ( ! empty( $this->output_schema ) ) { - $schema['properties']['output_schema'] = $this->output_schema; + if ( ! empty( $output_schema ) ) { + $schema['properties']['output_schema'] = $output_schema; $schema['required'][] = 'output_schema'; } From d2bc42579120833be32f6380e6af7cec737ef890 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Fri, 10 Oct 2025 23:28:25 -0400 Subject: [PATCH 3/7] feat: Add filters to to_array() and to_json_schema() methods Add wp_ability_{name}_to_array and wp_ability_{name}_to_json_schema filters to allow developers to modify the output of both methods. - Add filter documentation with @since tags - Pass ability instance as second parameter to filters - Add comprehensive test coverage for filter functionality - Verify filters receive correct parameters and modify output --- includes/abilities-api/class-wp-ability.php | 22 +++++- tests/unit/abilities-api/wpAbility.php | 86 +++++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index fb124405..13f29a76 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -372,7 +372,7 @@ public function get_meta_item( string $key, $default_value = null ) { * } */ public function to_array(): array { - return array( + $array = array( 'name' => $this->get_name(), 'label' => $this->get_label(), 'description' => $this->get_description(), @@ -380,6 +380,16 @@ public function to_array(): array { 'output_schema' => $this->get_output_schema(), 'meta' => $this->get_meta(), ); + + /** + * Filters the array representation of an ability. + * + * @since n.e.x.t + * + * @param array $array The ability as an associative array. + * @param \WP_Ability $ability The ability instance. + */ + return apply_filters( "wp_ability_{$this->get_name()}_to_array", $array, $this ); } /** @@ -437,7 +447,15 @@ public function to_json_schema(): array { $schema['required'][] = 'output_schema'; } - return $schema; + /** + * Filters the JSON Schema representation of an ability. + * + * @since n.e.x.t + * + * @param array $schema The JSON Schema representation. + * @param \WP_Ability $ability The ability instance. + */ + return apply_filters( "wp_ability_{$this->get_name()}_to_json_schema", $schema, $this ); } /** diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 8182a10f..69e57057 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -909,4 +909,90 @@ public function test_to_json_schema_name_is_constant() { $this->assertSame( self::$test_ability_name, $schema['properties']['name']['const'], 'Name const should match ability name' ); $this->assertContains( 'name', $schema['required'], 'name should be in required array' ); } + + /** + * Tests that to_array() filter is applied. + */ + public function test_to_array_filter() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $filter_callback = static function ( $array, $ability_instance ) { + $array['custom_field'] = 'custom_value'; + return $array; + }; + + add_filter( 'wp_ability_test/calculator_to_array', $filter_callback, 10, 2 ); + + $array = $ability->to_array(); + + remove_filter( 'wp_ability_test/calculator_to_array', $filter_callback ); + + $this->assertArrayHasKey( 'custom_field', $array, 'Filtered array should contain custom field' ); + $this->assertSame( 'custom_value', $array['custom_field'], 'Custom field value should match' ); + } + + /** + * Tests that to_json_schema() filter is applied. + */ + public function test_to_json_schema_filter() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $filter_callback = static function ( $schema, $ability_instance ) { + $schema['custom_property'] = 'custom_schema_value'; + return $schema; + }; + + add_filter( 'wp_ability_test/calculator_to_json_schema', $filter_callback, 10, 2 ); + + $schema = $ability->to_json_schema(); + + remove_filter( 'wp_ability_test/calculator_to_json_schema', $filter_callback ); + + $this->assertArrayHasKey( 'custom_property', $schema, 'Filtered schema should contain custom property' ); + $this->assertSame( 'custom_schema_value', $schema['custom_property'], 'Custom property value should match' ); + } + + /** + * Tests that to_array() filter receives ability instance as second parameter. + */ + public function test_to_array_filter_receives_ability_instance() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $received_ability = null; + + $filter_callback = static function ( $array, $ability_instance ) use ( &$received_ability ) { + $received_ability = $ability_instance; + return $array; + }; + + add_filter( 'wp_ability_test/calculator_to_array', $filter_callback, 10, 2 ); + + $ability->to_array(); + + remove_filter( 'wp_ability_test/calculator_to_array', $filter_callback ); + + $this->assertInstanceOf( WP_Ability::class, $received_ability, 'Filter should receive WP_Ability instance' ); + $this->assertSame( self::$test_ability_name, $received_ability->get_name(), 'Received ability should match' ); + } + + /** + * Tests that to_json_schema() filter receives ability instance as second parameter. + */ + public function test_to_json_schema_filter_receives_ability_instance() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + $received_ability = null; + + $filter_callback = static function ( $schema, $ability_instance ) use ( &$received_ability ) { + $received_ability = $ability_instance; + return $schema; + }; + + add_filter( 'wp_ability_test/calculator_to_json_schema', $filter_callback, 10, 2 ); + + $ability->to_json_schema(); + + remove_filter( 'wp_ability_test/calculator_to_json_schema', $filter_callback ); + + $this->assertInstanceOf( WP_Ability::class, $received_ability, 'Filter should receive WP_Ability instance' ); + $this->assertSame( self::$test_ability_name, $received_ability->get_name(), 'Received ability should match' ); + } } From 1684e1d93029631950d46be0683aadc1f36e5e53 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Sat, 11 Oct 2025 19:31:10 -0400 Subject: [PATCH 4/7] feat: Implement JsonSerializable interface for WP_Ability Add JsonSerializable interface to allow abilities to be passed directly to json_encode() without manually calling to_array(). - Implement jsonSerialize() method that delegates to to_array() - Automatically applies wp_ability_{name}_to_array filter - Add 4 comprehensive tests for JsonSerializable functionality - Verify json_encode() works correctly with WP_Ability instances Addresses feedback from @westonruter in PR #109. --- includes/abilities-api/class-wp-ability.php | 16 +++++- tests/unit/abilities-api/wpAbility.php | 64 +++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 13f29a76..99613902 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -18,7 +18,7 @@ * * @see WP_Abilities_Registry */ -class WP_Ability { +class WP_Ability implements \JsonSerializable { /** * The default value for the `show_in_rest` meta. @@ -392,6 +392,20 @@ public function to_array(): array { return apply_filters( "wp_ability_{$this->get_name()}_to_array", $array, $this ); } + /** + * Serializes the ability to a value that can be serialized natively by json_encode(). + * + * Implements the JsonSerializable interface to allow the ability to be passed + * directly to json_encode() without manually calling to_array(). + * + * @since n.e.x.t + * + * @return array The ability as an associative array. + */ + public function jsonSerialize(): array { + return $this->to_array(); + } + /** * Converts the ability to a JSON Schema representation. * diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index 69e57057..962b4954 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -995,4 +995,68 @@ public function test_to_json_schema_filter_receives_ability_instance() { $this->assertInstanceOf( WP_Ability::class, $received_ability, 'Filter should receive WP_Ability instance' ); $this->assertSame( self::$test_ability_name, $received_ability->get_name(), 'Received ability should match' ); } + + /** + * Tests that WP_Ability implements JsonSerializable. + */ + public function test_implements_json_serializable() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $this->assertInstanceOf( JsonSerializable::class, $ability, 'WP_Ability should implement JsonSerializable' ); + } + + /** + * Tests that json_encode() works with WP_Ability. + */ + public function test_json_encode() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $json = json_encode( $ability ); + + $this->assertIsString( $json, 'json_encode should return a string' ); + $this->assertNotFalse( $json, 'json_encode should not fail' ); + + $decoded = json_decode( $json, true ); + + $this->assertIsArray( $decoded, 'Decoded JSON should be an array' ); + $this->assertArrayHasKey( 'name', $decoded, 'Decoded array should contain name' ); + $this->assertSame( self::$test_ability_name, $decoded['name'], 'Name should match' ); + $this->assertArrayHasKey( 'label', $decoded, 'Decoded array should contain label' ); + $this->assertArrayHasKey( 'description', $decoded, 'Decoded array should contain description' ); + $this->assertArrayHasKey( 'meta', $decoded, 'Decoded array should contain meta' ); + } + + /** + * Tests that jsonSerialize() returns same data as to_array(). + */ + public function test_json_serialize_matches_to_array() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $array = $ability->to_array(); + $json_serialize = $ability->jsonSerialize(); + + $this->assertSame( $array, $json_serialize, 'jsonSerialize() should return the same data as to_array()' ); + } + + /** + * Tests that json_encode() applies to_array() filter. + */ + public function test_json_encode_applies_filter() { + $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); + + $filter_callback = static function ( $array, $ability_instance ) { + $array['filtered'] = true; + return $array; + }; + + add_filter( 'wp_ability_test/calculator_to_array', $filter_callback, 10, 2 ); + + $json = json_encode( $ability ); + $decoded = json_decode( $json, true ); + + remove_filter( 'wp_ability_test/calculator_to_array', $filter_callback ); + + $this->assertArrayHasKey( 'filtered', $decoded, 'json_encode should apply to_array filter' ); + $this->assertTrue( $decoded['filtered'], 'Filtered value should be present' ); + } } From c6b6dc938f96913eb336bf3d573f17d2a00c1e1f Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 13 Oct 2025 11:54:11 -0400 Subject: [PATCH 5/7] Improved to_array() method documentation - Clarified that $meta array may contain additional custom keys beyond documented ones - Added @type mixed ...$0 parameter to document extensibility of meta array - Fixed alignment of @type bool $show_in_rest for consistency --- includes/abilities-api/class-wp-ability.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 99613902..50b446b9 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -357,7 +357,7 @@ public function get_meta_item( string $key, $default_value = null ) { * @type array $input_schema The input validation schema. * @type array $output_schema The output validation schema. * @type array $meta { - * Metadata for the ability. + * Metadata for the ability. May contain additional custom keys beyond those documented below. * * @type array $annotations { * Behavior annotations. @@ -367,7 +367,8 @@ public function get_meta_item( string $key, $default_value = null ) { * @type bool $destructive Whether the ability is destructive. * @type bool $idempotent Whether the ability is idempotent. * } - * @type bool $show_in_rest Whether the ability is exposed in REST API. + * @type bool $show_in_rest Whether the ability is exposed in REST API. + * @type mixed ...$0 Additional custom metadata keys. * } * } */ From 25d2e4cc8f174734b0b94ba3327ab611f7b69368 Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 13 Oct 2025 19:31:28 -0400 Subject: [PATCH 6/7] feat: Add to_array() and JsonSerializable to WP_Ability_Category - Implement JsonSerializable interface for WP_Ability_Category - Add to_array() method returning slug, label, description, and meta - Add wp_ability_category_{slug}_to_array filter - Add 9 comprehensive tests covering array conversion and JSON serialization Maintains consistency with WP_Ability implementation pattern. --- .../class-wp-ability-category.php | 52 +++- .../unit/abilities-api/wpAbilityCategory.php | 265 ++++++++++++++++++ 2 files changed, 316 insertions(+), 1 deletion(-) diff --git a/includes/abilities-api/class-wp-ability-category.php b/includes/abilities-api/class-wp-ability-category.php index 722a1949..9493c6dc 100644 --- a/includes/abilities-api/class-wp-ability-category.php +++ b/includes/abilities-api/class-wp-ability-category.php @@ -18,7 +18,7 @@ * * @see WP_Abilities_Category_Registry */ -final class WP_Ability_Category { +final class WP_Ability_Category implements \JsonSerializable { /** * The unique slug for the category. @@ -181,6 +181,56 @@ public function get_meta(): array { return $this->meta; } + /** + * Converts the category to an array representation. + * + * Returns a complete array representation of the category including slug, label, + * description, and metadata. + * + * @since n.e.x.t + * + * @return array { + * The category as an associative array. + * + * @type string $slug The unique category slug. + * @type string $label The human-readable label. + * @type string $description The detailed description. + * @type array $meta Optional metadata for the category. + * } + */ + public function to_array(): array { + $array = array( + 'slug' => $this->get_slug(), + 'label' => $this->get_label(), + 'description' => $this->get_description(), + 'meta' => $this->get_meta(), + ); + + /** + * Filters the array representation of a category. + * + * @since n.e.x.t + * + * @param array $array The category as an associative array. + * @param \WP_Ability_Category $category The category instance. + */ + return apply_filters( "wp_ability_category_{$this->get_slug()}_to_array", $array, $this ); + } + + /** + * Serializes the category to a value that can be serialized natively by json_encode(). + * + * Implements the JsonSerializable interface to allow the category to be passed + * directly to json_encode() without manually calling to_array(). + * + * @since n.e.x.t + * + * @return array The category as an associative array. + */ + public function jsonSerialize(): array { + return $this->to_array(); + } + /** * Wakeup magic method. * diff --git a/tests/unit/abilities-api/wpAbilityCategory.php b/tests/unit/abilities-api/wpAbilityCategory.php index c82056c4..0c415a3f 100644 --- a/tests/unit/abilities-api/wpAbilityCategory.php +++ b/tests/unit/abilities-api/wpAbilityCategory.php @@ -760,4 +760,269 @@ public function test_register_category_with_unknown_property(): void { // But _doing_it_wrong should be triggered. $this->assertDoingItWrongTriggered( 'WP_Ability_Category::__construct', 'not a valid property' ); } + + /** + * Test category to_array() returns correct structure. + */ + public function test_category_to_array_structure(): void { + $category = $this->register_category_during_hook( + 'test-math', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $array = $category->to_array(); + + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'slug', $array ); + $this->assertArrayHasKey( 'label', $array ); + $this->assertArrayHasKey( 'description', $array ); + $this->assertArrayHasKey( 'meta', $array ); + + $this->assertSame( 'test-math', $array['slug'] ); + $this->assertSame( 'Math', $array['label'] ); + $this->assertSame( 'Mathematical operations.', $array['description'] ); + $this->assertSame( array(), $array['meta'] ); + } + + /** + * Test category to_array() includes meta. + */ + public function test_category_to_array_with_meta(): void { + $meta = array( + 'icon' => 'dashicons-calculator', + 'priority' => 10, + 'custom' => array( 'key' => 'value' ), + ); + + $category = $this->register_category_during_hook( + 'test-meta', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => $meta, + ) + ); + + $array = $category->to_array(); + + $this->assertIsArray( $array ); + $this->assertArrayHasKey( 'meta', $array ); + $this->assertSame( $meta, $array['meta'] ); + $this->assertSame( 'dashicons-calculator', $array['meta']['icon'] ); + $this->assertSame( 10, $array['meta']['priority'] ); + $this->assertSame( array( 'key' => 'value' ), $array['meta']['custom'] ); + } + + /** + * Test category to_array() filter is applied. + */ + public function test_category_to_array_filter(): void { + $category = $this->register_category_during_hook( + 'test-filter', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + add_filter( + 'wp_ability_category_test-filter_to_array', + static function ( $array ) { + $array['custom_field'] = 'custom_value'; + return $array; + } + ); + + $array = $category->to_array(); + + $this->assertArrayHasKey( 'custom_field', $array ); + $this->assertSame( 'custom_value', $array['custom_field'] ); + } + + /** + * Test category to_array() filter receives correct parameters. + */ + public function test_category_to_array_filter_parameters(): void { + $category = $this->register_category_during_hook( + 'test-params', + array( + 'label' => 'System', + 'description' => 'System operations.', + ) + ); + + $filter_called = false; + $received_array = null; + $received_object = null; + + add_filter( + 'wp_ability_category_test-params_to_array', + static function ( $array, $cat ) use ( &$filter_called, &$received_array, &$received_object ) { + $filter_called = true; + $received_array = $array; + $received_object = $cat; + return $array; + }, + 10, + 2 + ); + + $category->to_array(); + + $this->assertTrue( $filter_called, 'Filter should have been called' ); + $this->assertIsArray( $received_array, 'First parameter should be an array' ); + $this->assertInstanceOf( WP_Ability_Category::class, $received_object, 'Second parameter should be WP_Ability_Category instance' ); + $this->assertSame( $category, $received_object, 'Second parameter should be the same category instance' ); + } + + /** + * Test category to_array() filter can modify all fields. + */ + public function test_category_to_array_filter_modify_fields(): void { + $category = $this->register_category_during_hook( + 'test-modify', + array( + 'label' => 'Original', + 'description' => 'Original description.', + 'meta' => array( 'foo' => 'bar' ), + ) + ); + + add_filter( + 'wp_ability_category_test-modify_to_array', + static function ( $array ) { + $array['label'] = 'Modified Label'; + $array['description'] = 'Modified Description'; + $array['meta']['new_field'] = 'new_value'; + unset( $array['meta']['foo'] ); + return $array; + } + ); + + $array = $category->to_array(); + + $this->assertSame( 'Modified Label', $array['label'] ); + $this->assertSame( 'Modified Description', $array['description'] ); + $this->assertArrayHasKey( 'new_field', $array['meta'] ); + $this->assertSame( 'new_value', $array['meta']['new_field'] ); + $this->assertArrayNotHasKey( 'foo', $array['meta'] ); + } + + /** + * Test category implements JsonSerializable interface. + */ + public function test_category_implements_json_serializable(): void { + $category = $this->register_category_during_hook( + 'test-json', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + $this->assertInstanceOf( \JsonSerializable::class, $category ); + } + + /** + * Test category can be json_encode()'d directly. + */ + public function test_category_json_encode(): void { + $category = $this->register_category_during_hook( + 'test-encode', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + 'meta' => array( 'icon' => 'dashicons-calculator' ), + ) + ); + + $json = json_encode( $category ); + $this->assertNotFalse( $json, 'json_encode should not fail' ); + + $decoded = json_decode( $json, true ); + $this->assertIsArray( $decoded ); + $this->assertSame( 'test-encode', $decoded['slug'] ); + $this->assertSame( 'Math', $decoded['label'] ); + $this->assertSame( 'Mathematical operations.', $decoded['description'] ); + $this->assertSame( array( 'icon' => 'dashicons-calculator' ), $decoded['meta'] ); + } + + /** + * Test category jsonSerialize() returns same as to_array(). + */ + public function test_category_json_serialize_matches_to_array(): void { + $category = $this->register_category_during_hook( + 'test-match', + array( + 'label' => 'System', + 'description' => 'System operations.', + 'meta' => array( 'priority' => 5 ), + ) + ); + + $to_array_result = $category->to_array(); + $json_serialize = $category->jsonSerialize(); + + $this->assertSame( $to_array_result, $json_serialize, 'jsonSerialize() should return same result as to_array()' ); + } + + /** + * Test category jsonSerialize() respects filters. + */ + public function test_category_json_serialize_respects_filters(): void { + $category = $this->register_category_during_hook( + 'test-json-filter', + array( + 'label' => 'Math', + 'description' => 'Mathematical operations.', + ) + ); + + add_filter( + 'wp_ability_category_test-json-filter_to_array', + static function ( $array ) { + $array['filtered'] = true; + return $array; + } + ); + + $json = json_encode( $category ); + $decoded = json_decode( $json, true ); + + $this->assertArrayHasKey( 'filtered', $decoded ); + $this->assertTrue( $decoded['filtered'] ); + } + + /** + * Test category to_array() with complex nested meta. + */ + public function test_category_to_array_with_nested_meta(): void { + $meta = array( + 'level1' => array( + 'level2' => array( + 'level3' => 'deep value', + 'array' => array( 1, 2, 3 ), + ), + ), + 'simple' => 'value', + ); + + $category = $this->register_category_during_hook( + 'test-nested', + array( + 'label' => 'Complex', + 'description' => 'Complex meta structure.', + 'meta' => $meta, + ) + ); + + $array = $category->to_array(); + + $this->assertSame( $meta, $array['meta'] ); + $this->assertSame( 'deep value', $array['meta']['level1']['level2']['level3'] ); + $this->assertSame( array( 1, 2, 3 ), $array['meta']['level1']['level2']['array'] ); + } } From 87d69897027856c7f410956676db850a64680e1a Mon Sep 17 00:00:00 2001 From: Gustavo Bordoni Date: Mon, 13 Oct 2025 19:36:59 -0400 Subject: [PATCH 7/7] refactor: Update to_json_schema() to use JSON Schema Draft 4 - Change schema version from Draft 7 to Draft 4 for WP REST API compatibility - Replace `const` keyword with `enum` array (Draft 4 doesn't support const) - Update test assertions to check for enum instead of const - Update docblock to reflect Draft 4 compliance All 100 tests passing with new schema format. --- includes/abilities-api/class-wp-ability.php | 8 ++++---- tests/unit/abilities-api/wpAbility.php | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/includes/abilities-api/class-wp-ability.php b/includes/abilities-api/class-wp-ability.php index 265a43f2..206b260b 100644 --- a/includes/abilities-api/class-wp-ability.php +++ b/includes/abilities-api/class-wp-ability.php @@ -436,7 +436,7 @@ public function jsonSerialize(): array { /** * Converts the ability to a JSON Schema representation. * - * Generates a JSON Schema Draft 7 compliant schema describing the ability's + * Generates a JSON Schema Draft 4 compliant schema describing the ability's * structure, including input/output schemas and metadata. * * @since n.e.x.t @@ -448,14 +448,14 @@ public function to_json_schema(): array { $output_schema = $this->get_output_schema(); $schema = array( - '$schema' => 'http://json-schema.org/draft-07/schema#', + '$schema' => 'http://json-schema.org/draft-04/schema#', 'type' => 'object', 'title' => $this->get_label(), 'description' => $this->get_description(), 'properties' => array( 'name' => array( - 'type' => 'string', - 'const' => $this->get_name(), + 'type' => 'string', + 'enum' => array( $this->get_name() ), ), 'meta' => array( 'type' => 'object', diff --git a/tests/unit/abilities-api/wpAbility.php b/tests/unit/abilities-api/wpAbility.php index d0607a09..85d80bb1 100644 --- a/tests/unit/abilities-api/wpAbility.php +++ b/tests/unit/abilities-api/wpAbility.php @@ -856,7 +856,7 @@ public function test_to_json_schema_returns_valid_structure() { $this->assertIsArray( $schema, 'to_json_schema() should return an array' ); $this->assertArrayHasKey( '$schema', $schema, 'Schema should contain $schema key' ); - $this->assertSame( 'http://json-schema.org/draft-07/schema#', $schema['$schema'], 'Schema version should be Draft 7' ); + $this->assertSame( 'http://json-schema.org/draft-04/schema#', $schema['$schema'], 'Schema version should be Draft 4' ); $this->assertArrayHasKey( 'type', $schema, 'Schema should contain type key' ); $this->assertSame( 'object', $schema['type'], 'Schema type should be object' ); $this->assertArrayHasKey( 'title', $schema, 'Schema should contain title key' ); @@ -931,15 +931,17 @@ public function test_to_json_schema_meta_structure() { } /** - * Tests to_json_schema() name property uses const. + * Tests to_json_schema() name property uses enum for constant value. */ public function test_to_json_schema_name_is_constant() { $ability = new WP_Ability( self::$test_ability_name, self::$test_ability_properties ); $schema = $ability->to_json_schema(); $this->assertArrayHasKey( 'name', $schema['properties'], 'Properties should contain name' ); - $this->assertArrayHasKey( 'const', $schema['properties']['name'], 'Name should have const keyword' ); - $this->assertSame( self::$test_ability_name, $schema['properties']['name']['const'], 'Name const should match ability name' ); + $this->assertArrayHasKey( 'enum', $schema['properties']['name'], 'Name should have enum keyword' ); + $this->assertIsArray( $schema['properties']['name']['enum'], 'Name enum should be an array' ); + $this->assertCount( 1, $schema['properties']['name']['enum'], 'Name enum should have exactly one value' ); + $this->assertSame( self::$test_ability_name, $schema['properties']['name']['enum'][0], 'Name enum value should match ability name' ); $this->assertContains( 'name', $schema['required'], 'name should be in required array' ); }