Skip to content

Commit 0d92137

Browse files
Sukhendu2002gzioloclaude
committed
Abilities API: Prepare object results for JSON clients.
Prepare empty objects and nested values using their output schemas, reusing REST schema matching and validation helpers. Prefer array branches only when the value satisfies their array constraints, including nested branches and explicit or inferred types. Prepare complete AI client results and nested properties of associative REST results. Keep REST root empty arrays and numeric collections unchanged for compatibility with the server's _fields and _embed processing. Add focused schema regression tests and coverage for both client adapters. Validation: 774 tests and 1,474 assertions, with two existing PHPUnit warnings; coding standards and PHP compatibility checks pass. Co-authored-by: Grzegorz Ziolkowski <grzegorz@gziolo.pl> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 065c362 commit 0d92137

7 files changed

Lines changed: 756 additions & 0 deletions

File tree

‎src/wp-includes/ai-client/class-wp-ai-client-ability-function-resolver.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ public function execute_ability( FunctionCall $call ): FunctionResponse {
148148
);
149149
}
150150

151+
$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );
152+
151153
return new FunctionResponse(
152154
$function_id,
153155
$function_name,

‎src/wp-includes/json-schema.php‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,116 @@ function wp_prepare_json_schema_for_client( array $schema, string $schema_profil
9393
return _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema, $allowed_keywords );
9494
}
9595

96+
/**
97+
* Prepares a JSON value for clients according to its schema.
98+
*
99+
* Empty PHP arrays are converted to objects when the schema allows objects but
100+
* not arrays. Nested values are prepared using matching anyOf or oneOf branches
101+
* and their property or item schemas.
102+
*
103+
* @since 7.2.0
104+
*
105+
* @param mixed $value The value to prepare.
106+
* @param array<string, mixed> $schema The schema describing the value.
107+
* @return mixed The prepared value.
108+
*/
109+
function wp_prepare_json_value_for_client( $value, array $schema ) {
110+
if ( $value instanceof JsonSerializable ) {
111+
$value = $value->jsonSerialize();
112+
}
113+
114+
if ( ! is_array( $value ) && ! $value instanceof stdClass ) {
115+
return $value;
116+
}
117+
118+
foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
119+
if ( ! isset( $schema[ $keyword ] ) ) {
120+
continue;
121+
}
122+
123+
$matching_schema = null;
124+
125+
/*
126+
* Prefer an array schema for PHP lists. An empty PHP array also matches an
127+
* object schema, but should remain [] when an array schema accepts it.
128+
*/
129+
if ( is_array( $value ) && array_is_list( $value ) ) {
130+
foreach ( $schema[ $keyword ] as $candidate_schema ) {
131+
if ( ! isset( $candidate_schema['type'] ) && isset( $schema['type'] ) ) {
132+
$candidate_schema['type'] = $schema['type'];
133+
}
134+
135+
if ( _wp_json_schema_matches_type( $value, $candidate_schema, 'array' ) ) {
136+
$matching_schema = $candidate_schema;
137+
break;
138+
}
139+
}
140+
}
141+
142+
// Like rest_filter_response_by_context(), use the first matching oneOf branch.
143+
if ( null === $matching_schema ) {
144+
$matching_schema = 'anyOf' === $keyword
145+
? rest_find_any_matching_schema( $value, $schema, '' )
146+
: rest_find_one_matching_schema( $value, $schema, '', true );
147+
}
148+
149+
if ( is_wp_error( $matching_schema ) ) {
150+
continue;
151+
}
152+
153+
if ( ! isset( $schema['type'] ) && isset( $matching_schema['type'] ) ) {
154+
$schema['type'] = $matching_schema['type'];
155+
}
156+
157+
$value = wp_prepare_json_value_for_client( $value, $matching_schema );
158+
}
159+
160+
$is_array_value = is_array( $value );
161+
$types = isset( $schema['type'] ) ? (array) $schema['type'] : array();
162+
$allows_object = in_array( 'object', $types, true );
163+
$allows_array = in_array( 'array', $types, true );
164+
165+
if ( $is_array_value && empty( $value ) && $allows_object && ! $allows_array ) {
166+
return (object) $value;
167+
}
168+
169+
$is_object_value = $value instanceof stdClass || ( $is_array_value && $allows_object && ( ! $allows_array || ! array_is_list( $value ) ) );
170+
if ( $allows_object && $is_object_value ) {
171+
if ( $value instanceof stdClass ) {
172+
$value = clone $value;
173+
}
174+
175+
foreach ( $value as $property => $property_value ) {
176+
$property_schema = null;
177+
if ( isset( $schema['properties'][ $property ] ) && is_array( $schema['properties'][ $property ] ) ) {
178+
$property_schema = $schema['properties'][ $property ];
179+
} else {
180+
$property_schema = rest_find_matching_pattern_property_schema( $property, $schema );
181+
if ( null === $property_schema && isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
182+
$property_schema = $schema['additionalProperties'];
183+
}
184+
}
185+
186+
if ( null === $property_schema ) {
187+
continue;
188+
}
189+
190+
$property_value = wp_prepare_json_value_for_client( $property_value, $property_schema );
191+
if ( is_array( $value ) ) {
192+
$value[ $property ] = $property_value;
193+
} else {
194+
$value->$property = $property_value;
195+
}
196+
}
197+
} elseif ( $allows_array && $is_array_value && isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
198+
foreach ( $value as $index => $item ) {
199+
$value[ $index ] = wp_prepare_json_value_for_client( $item, $schema['items'] );
200+
}
201+
}
202+
203+
return $value;
204+
}
205+
96206
/**
97207
* Prepares a JSON Schema for clients using a given keyword lookup.
98208
*
@@ -199,3 +309,56 @@ function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema
199309

200310
return $schema;
201311
}
312+
313+
/**
314+
* Determines whether a value matches a JSON Schema through a given type.
315+
*
316+
* Combining schemas must have a matching branch that allows the requested type.
317+
* Finding the type in one branch and validating the value against another branch
318+
* is not sufficient.
319+
*
320+
* @since 7.2.0
321+
* @access private
322+
*
323+
* @param mixed $value The value to validate.
324+
* @param array<string, mixed> $schema The schema to inspect.
325+
* @param string $type The type to match.
326+
* @return bool Whether the value matches the schema through the requested type.
327+
*/
328+
function _wp_json_schema_matches_type( $value, array $schema, string $type ): bool {
329+
if ( isset( $schema['type'] ) && ! in_array( $type, (array) $schema['type'], true ) ) {
330+
return false;
331+
}
332+
333+
foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
334+
if ( ! isset( $schema[ $keyword ] ) ) {
335+
continue;
336+
}
337+
338+
foreach ( $schema[ $keyword ] as $sub_schema ) {
339+
if ( ! is_array( $sub_schema ) ) {
340+
continue;
341+
}
342+
343+
// Match rest_find_any_matching_schema(), which applies the parent type to untyped branches.
344+
if ( ! isset( $sub_schema['type'] ) && isset( $schema['type'] ) ) {
345+
$sub_schema['type'] = $schema['type'];
346+
}
347+
348+
if ( _wp_json_schema_matches_type( $value, $sub_schema, $type ) ) {
349+
continue 2;
350+
}
351+
}
352+
353+
return false;
354+
}
355+
356+
if ( ! isset( $schema['type'] ) && ! isset( $schema['anyOf'] ) && ! isset( $schema['oneOf'] ) ) {
357+
return false;
358+
}
359+
360+
// Explicit and inferred types must both validate through the requested type only.
361+
$schema['type'] = $type;
362+
363+
return ! is_wp_error( rest_validate_value_from_schema( $value, $schema ) );
364+
}

‎src/wp-includes/rest-api/endpoints/class-wp-rest-abilities-v1-run-controller.php‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,20 @@ public function execute_ability( $request ) {
9595
return $result;
9696
}
9797

98+
/*
99+
* Preserve empty objects in nested properties when encoding an associative
100+
* result as JSON. Preparation can convert empty PHP arrays to stdClass.
101+
*
102+
* The REST server's `_fields` and `_embed` processing expects arrays at the
103+
* response root and for numeric collection items. Converting those values
104+
* to objects would cause type errors, so skip root empty arrays and numeric
105+
* collections until that processing supports objects. An empty object result
106+
* therefore still encodes as [], and a list containing one encodes as [[]].
107+
*/
108+
if ( is_array( $result ) && ! wp_is_numeric_array( $result ) ) {
109+
$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );
110+
}
111+
98112
return rest_ensure_response( $result );
99113
}
100114

‎tests/phpunit/includes/wp-ai-client-test-abilities-trait.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,24 @@ private static function register_test_abilities() {
8484
)
8585
);
8686

87+
wp_register_ability(
88+
'wpaiclienttests/empty-object',
89+
array(
90+
'label' => 'Empty Object Test Ability',
91+
'description' => 'An ability that returns an empty object.',
92+
'category' => 'wpaiclienttests',
93+
'output_schema' => array(
94+
'type' => 'object',
95+
),
96+
'execute_callback' => static function () {
97+
return array();
98+
},
99+
'permission_callback' => static function () {
100+
return true;
101+
},
102+
)
103+
);
104+
87105
wp_register_ability(
88106
'wpaiclienttests/returns-error',
89107
array(
@@ -127,6 +145,7 @@ private static function unregister_test_abilities() {
127145
// Unregister test abilities.
128146
wp_unregister_ability( 'wpaiclienttests/simple' );
129147
wp_unregister_ability( 'wpaiclienttests/with-params' );
148+
wp_unregister_ability( 'wpaiclienttests/empty-object' );
130149
wp_unregister_ability( 'wpaiclienttests/returns-error' );
131150
wp_unregister_ability( 'wpaiclienttests/hyphen-test' );
132151

‎tests/phpunit/tests/ai-client/wpAiClientAbilityFunctionResolver.php‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,25 @@ public function test_execute_ability_success() {
502502
$this->assertTrue( $data['success'] );
503503
}
504504

505+
/**
506+
* Tests that an empty object result is prepared for the AI client.
507+
*
508+
* @ticket 66267
509+
*/
510+
public function test_execute_ability_prepares_empty_object_result() {
511+
$resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/empty-object' );
512+
$call = new FunctionCall(
513+
'test-id',
514+
'wpab__wpaiclienttests__empty-object',
515+
array()
516+
);
517+
518+
$response = $resolver->execute_ability( $call );
519+
520+
$this->assertSame( '{}', wp_json_encode( $response->getResponse() ) );
521+
$this->assertSame( array(), wp_get_ability( 'wpaiclienttests/empty-object' )->execute() );
522+
}
523+
505524
/**
506525
* Test execute_ability with parameters.
507526
*

0 commit comments

Comments
 (0)