Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ public function execute_ability( FunctionCall $call ): FunctionResponse {
);
}

$result = wp_prepare_json_value_for_client( $result, $ability->get_output_schema() );

return new FunctionResponse(
$function_id,
$function_name,
Expand Down
163 changes: 163 additions & 0 deletions src/wp-includes/json-schema.php
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,116 @@ function wp_prepare_json_schema_for_client( array $schema, string $schema_profil
return _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema, $allowed_keywords );
}

/**
* Prepares a JSON value for clients according to its schema.
*
* Empty PHP arrays are converted to objects when the schema allows objects but
* not arrays. Nested values are prepared using matching anyOf or oneOf branches
* and their property or item schemas.
*
* @since 7.2.0
*
* @param mixed $value The value to prepare.
* @param array<string, mixed> $schema The schema describing the value.
* @return mixed The prepared value.
*/
function wp_prepare_json_value_for_client( $value, array $schema ) {
if ( $value instanceof JsonSerializable ) {
$value = $value->jsonSerialize();
}

if ( ! is_array( $value ) && ! $value instanceof stdClass ) {
return $value;
}

foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
if ( ! isset( $schema[ $keyword ] ) ) {
continue;
}

$matching_schema = null;

/*
* Prefer an array schema for PHP lists. An empty PHP array also matches an
* object schema, but should remain [] when an array schema accepts it.
*/
if ( is_array( $value ) && array_is_list( $value ) ) {
foreach ( $schema[ $keyword ] as $candidate_schema ) {
if ( ! isset( $candidate_schema['type'] ) && isset( $schema['type'] ) ) {
$candidate_schema['type'] = $schema['type'];
}

if ( _wp_json_schema_matches_type( $value, $candidate_schema, 'array' ) ) {
$matching_schema = $candidate_schema;
break;
}
}
}

// Like rest_filter_response_by_context(), use the first matching oneOf branch.
if ( null === $matching_schema ) {
$matching_schema = 'anyOf' === $keyword
? rest_find_any_matching_schema( $value, $schema, '' )
: rest_find_one_matching_schema( $value, $schema, '', true );
}

if ( is_wp_error( $matching_schema ) ) {
continue;
}

if ( ! isset( $schema['type'] ) && isset( $matching_schema['type'] ) ) {
$schema['type'] = $matching_schema['type'];
}

$value = wp_prepare_json_value_for_client( $value, $matching_schema );
}

$is_array_value = is_array( $value );
$types = isset( $schema['type'] ) ? (array) $schema['type'] : array();
$allows_object = in_array( 'object', $types, true );
$allows_array = in_array( 'array', $types, true );

if ( $is_array_value && empty( $value ) && $allows_object && ! $allows_array ) {
return (object) $value;
}

$is_object_value = $value instanceof stdClass || ( $is_array_value && $allows_object && ( ! $allows_array || ! array_is_list( $value ) ) );
if ( $allows_object && $is_object_value ) {
if ( $value instanceof stdClass ) {
$value = clone $value;
}

foreach ( $value as $property => $property_value ) {
$property_schema = null;
if ( isset( $schema['properties'][ $property ] ) && is_array( $schema['properties'][ $property ] ) ) {
$property_schema = $schema['properties'][ $property ];
} else {
$property_schema = rest_find_matching_pattern_property_schema( $property, $schema );
if ( null === $property_schema && isset( $schema['additionalProperties'] ) && is_array( $schema['additionalProperties'] ) ) {
$property_schema = $schema['additionalProperties'];
}
}

if ( null === $property_schema ) {
continue;
}

$property_value = wp_prepare_json_value_for_client( $property_value, $property_schema );
if ( is_array( $value ) ) {
$value[ $property ] = $property_value;
} else {
$value->$property = $property_value;
}
}
} elseif ( $allows_array && $is_array_value && isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
foreach ( $value as $index => $item ) {
$value[ $index ] = wp_prepare_json_value_for_client( $item, $schema['items'] );
}
}

return $value;
}

/**
* Prepares a JSON Schema for clients using a given keyword lookup.
*
Expand Down Expand Up @@ -199,3 +309,56 @@ function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema

return $schema;
}

/**
* Determines whether a value matches a JSON Schema through a given type.
*
* Combining schemas must have a matching branch that allows the requested type.
* Finding the type in one branch and validating the value against another branch
* is not sufficient.
*
* @since 7.2.0
* @access private
*
* @param mixed $value The value to validate.
* @param array<string, mixed> $schema The schema to inspect.
* @param string $type The type to match.
* @return bool Whether the value matches the schema through the requested type.
*/
function _wp_json_schema_matches_type( $value, array $schema, string $type ): bool {
if ( isset( $schema['type'] ) && ! in_array( $type, (array) $schema['type'], true ) ) {
return false;
}

foreach ( array( 'anyOf', 'oneOf' ) as $keyword ) {
if ( ! isset( $schema[ $keyword ] ) ) {
continue;
}

foreach ( $schema[ $keyword ] as $sub_schema ) {
if ( ! is_array( $sub_schema ) ) {
continue;
}

// Match rest_find_any_matching_schema(), which applies the parent type to untyped branches.
if ( ! isset( $sub_schema['type'] ) && isset( $schema['type'] ) ) {
$sub_schema['type'] = $schema['type'];
}

if ( _wp_json_schema_matches_type( $value, $sub_schema, $type ) ) {
continue 2;
}
}

return false;
}

if ( ! isset( $schema['type'] ) && ! isset( $schema['anyOf'] ) && ! isset( $schema['oneOf'] ) ) {
return false;
}

// Explicit and inferred types must both validate through the requested type only.
$schema['type'] = $type;

return ! is_wp_error( rest_validate_value_from_schema( $value, $schema ) );
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,20 @@ public function execute_ability( $request ) {
return $result;
}

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

return rest_ensure_response( $result );
}

Expand Down
19 changes: 19 additions & 0 deletions tests/phpunit/includes/wp-ai-client-test-abilities-trait.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,24 @@ private static function register_test_abilities() {
)
);

wp_register_ability(
'wpaiclienttests/empty-object',
array(
'label' => 'Empty Object Test Ability',
'description' => 'An ability that returns an empty object.',
'category' => 'wpaiclienttests',
'output_schema' => array(
'type' => 'object',
),
'execute_callback' => static function () {
return array();
},
'permission_callback' => static function () {
return true;
},
)
);

wp_register_ability(
'wpaiclienttests/returns-error',
array(
Expand Down Expand Up @@ -127,6 +145,7 @@ private static function unregister_test_abilities() {
// Unregister test abilities.
wp_unregister_ability( 'wpaiclienttests/simple' );
wp_unregister_ability( 'wpaiclienttests/with-params' );
wp_unregister_ability( 'wpaiclienttests/empty-object' );
wp_unregister_ability( 'wpaiclienttests/returns-error' );
wp_unregister_ability( 'wpaiclienttests/hyphen-test' );

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,25 @@ public function test_execute_ability_success() {
$this->assertTrue( $data['success'] );
}

/**
* Tests that an empty object result is prepared for the AI client.
*
* @ticket 66267
*/
public function test_execute_ability_prepares_empty_object_result() {
$resolver = new WP_AI_Client_Ability_Function_Resolver( 'wpaiclienttests/empty-object' );
$call = new FunctionCall(
'test-id',
'wpab__wpaiclienttests__empty-object',
array()
);

$response = $resolver->execute_ability( $call );

$this->assertSame( '{}', wp_json_encode( $response->getResponse() ) );
$this->assertSame( array(), wp_get_ability( 'wpaiclienttests/empty-object' )->execute() );
}

/**
* Test execute_ability with parameters.
*
Expand Down
Loading
Loading