Skip to content

Commit ffbc198

Browse files
committed
Abilities API: Reuse JSON Schema client preparation
Introduce `wp_prepare_json_schema_for_client()` to prepare JSON Schemas that are exposed to clients, and reuse it for Abilities REST responses and for the AI Client ability input schemas that become function declaration parameters. Ability schemas can include WordPress-internal schema conveniences that server-side REST validation accepts but that are not in portable JSON Schema draft-04 forms. Routing them through one shared helper keeps REST responses, frontend consumers, and and AI tool declarations aligned. Move the detailed schemae shared JSON Schema tests, and keep the REST and AI Client tests focused on integration wiring. Fixes #64955. git-svn-id: https://develop.svn.wordpress.org/trunk@62591 602fd350-edb4-49c9-b593-d223f7449a82
1 parent cd441a6 commit ffbc198

6 files changed

Lines changed: 507 additions & 652 deletions

File tree

src/wp-includes/ai-client/class-wp-ai-client-prompt-builder.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ public function using_abilities( ...$abilities ): self {
261261
}
262262

263263
$function_name = WP_AI_Client_Ability_Function_Resolver::ability_name_to_function_name( $ability->get_name() );
264-
$input_schema = $ability->get_input_schema();
264+
$input_schema = wp_prepare_json_schema_for_client( $ability->get_input_schema() );
265265

266266
$declarations[] = new FunctionDeclaration(
267267
$function_name,

src/wp-includes/json-schema.php

Lines changed: 144 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,17 @@
1010
/**
1111
* Gets the JSON Schema keywords allowed for a given schema profile.
1212
*
13-
* Use the returned list to decide which keywords to keep when a schema is
14-
* output as JSON. Both profiles describe JSON Schema draft-04 output, also
15-
* called JSON Schema Version 4. They differ only in how much of the keyword
16-
* vocabulary stays in the result.
13+
* Use this when preparing a schema that will be consumed outside of
14+
* WordPress's server-side validation, such as by REST clients, frontend code,
15+
* or AI providers.
1716
*
18-
* - 'rest-api' returns the subset of draft-04 that the WordPress REST API
19-
* uses for route output. This is the default.
20-
* - 'draft-04' returns the larger draft-04 set used when publishing a schema
21-
* as a standalone document to clients, such as the Abilities API. It keeps
22-
* documentation and passthrough keywords like '$ref', 'definitions',
23-
* 'allOf', 'not', 'dependencies', and 'additionalItems'.
17+
* The 'rest-api' profile returns the subset of JSON Schema draft-04 keywords
18+
* that the REST API has historically exposed. The 'draft-04' profile preserves
19+
* the larger draft-04 vocabulary used by clients that can consume standalone
20+
* schemas.
2421
*
25-
* The keywords are allowed to stay in the schema output. This does not mean
26-
* WordPress validates or sanitizes values against them.
22+
* Allowing a keyword to be exposed does not make WordPress validate or
23+
* sanitize values against it.
2724
*
2825
* @since 7.1.0
2926
*
@@ -60,7 +57,7 @@ function wp_get_json_schema_allowed_keywords( string $schema_profile = 'rest-api
6057
/**
6158
* Filters the JSON Schema keywords allowed for a given schema profile.
6259
*
63-
* Adding a keyword lets it stay in the schema output for that profile.
60+
* Use this to decide which keywords may be exposed to clients for a profile.
6461
* It does not make WordPress validate or sanitize values against the keyword.
6562
*
6663
* @since 7.1.0
@@ -70,3 +67,137 @@ function wp_get_json_schema_allowed_keywords( string $schema_profile = 'rest-api
7067
*/
7168
return apply_filters( 'wp_json_schema_allowed_keywords', $allowed_keywords, $schema_profile );
7269
}
70+
71+
/**
72+
* Prepares a JSON Schema for clients.
73+
*
74+
* Use this before exposing a schema outside of WordPress's server-side
75+
* validation, for example in REST responses, Ability metadata, or AI provider
76+
* requests. The prepared schema uses forms that JSON Schema draft-04 clients
77+
* can understand.
78+
*
79+
* WordPress-internal schema conveniences are converted or removed only where
80+
* needed to keep the exposed schema valid for the selected profile.
81+
*
82+
* @since 7.1.0
83+
*
84+
* @param array<string, mixed> $schema The schema array.
85+
* @param string $schema_profile Optional. Name of the schema profile
86+
* whose keywords should be preserved.
87+
* Default 'draft-04'.
88+
* @return array<string, mixed> The prepared schema.
89+
*/
90+
function wp_prepare_json_schema_for_client( array $schema, string $schema_profile = 'draft-04' ): array {
91+
$allowed_keywords = array_fill_keys( wp_get_json_schema_allowed_keywords( $schema_profile ), true );
92+
93+
return _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema, $allowed_keywords );
94+
}
95+
96+
/**
97+
* Prepares a JSON Schema for clients using a given keyword lookup.
98+
*
99+
* @since 7.1.0
100+
* @access private
101+
*
102+
* @param array<string, mixed> $schema The schema array.
103+
* @param array<string, true> $allowed_keywords Lookup map of allowed JSON Schema keywords.
104+
* @return array<string, mixed> The prepared schema.
105+
*/
106+
function _wp_prepare_json_schema_for_client_with_allowed_keywords( array $schema, array $allowed_keywords ): array {
107+
if ( isset( $schema['type'] ) && 'object' === $schema['type'] && isset( $schema['default'] ) ) {
108+
$default = $schema['default'];
109+
if ( is_array( $default ) && empty( $default ) ) {
110+
$schema['default'] = (object) $default;
111+
}
112+
}
113+
114+
$schema = array_intersect_key( $schema, $allowed_keywords );
115+
116+
/*
117+
* Collect draft-03 per-property `required: true` flags into a draft-04
118+
* `required` array of property names on the parent object schema.
119+
*
120+
* This mirrors rest_validate_object_value_from_schema(), where a draft-04
121+
* `required` array takes precedence: when one is present, per-property
122+
* booleans are ignored during validation. They are therefore left out of
123+
* the array here as well (but still stripped from the output) so the
124+
* published schema describes exactly what gets enforced.
125+
*/
126+
if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
127+
$has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] );
128+
$required = array();
129+
foreach ( $schema['properties'] as $property => &$property_schema ) {
130+
if ( is_array( $property_schema ) && ! wp_is_numeric_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) {
131+
if ( ! $has_required_array && true === $property_schema['required'] ) {
132+
$required[] = (string) $property;
133+
}
134+
unset( $property_schema['required'] );
135+
}
136+
}
137+
unset( $property_schema );
138+
139+
/*
140+
* Property keys are unique, so the collected list needs no deduplication.
141+
* When a draft-04 array is already present, leave it untouched.
142+
*/
143+
if ( ! $has_required_array && count( $required ) > 0 ) {
144+
$schema['required'] = $required;
145+
}
146+
}
147+
148+
/*
149+
* A boolean `required` outside of an object's property list has no draft-04
150+
* equivalent, so drop it rather than emit an invalid keyword.
151+
*/
152+
if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
153+
unset( $schema['required'] );
154+
}
155+
156+
/*
157+
* Sub-schema maps: keys are user-defined, values are sub-schemas.
158+
* Note: 'dependencies' values can also be property-dependency arrays
159+
* (numeric arrays of strings) which are skipped via wp_is_numeric_array().
160+
*/
161+
foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
162+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
163+
foreach ( $schema[ $keyword ] as $key => $child_schema ) {
164+
if ( is_array( $child_schema ) && ! wp_is_numeric_array( $child_schema ) ) {
165+
$schema[ $keyword ][ $key ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $child_schema, $allowed_keywords );
166+
}
167+
}
168+
}
169+
}
170+
171+
// Single sub-schema keywords.
172+
foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
173+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) && ! wp_is_numeric_array( $schema[ $keyword ] ) ) {
174+
$schema[ $keyword ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema[ $keyword ], $allowed_keywords );
175+
}
176+
}
177+
178+
// Items: single schema or tuple array of schemas.
179+
if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
180+
if ( ! wp_is_numeric_array( $schema['items'] ) ) {
181+
$schema['items'] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $schema['items'], $allowed_keywords );
182+
} else {
183+
foreach ( $schema['items'] as $index => $item_schema ) {
184+
if ( is_array( $item_schema ) && ! wp_is_numeric_array( $item_schema ) ) {
185+
$schema['items'][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $item_schema, $allowed_keywords );
186+
}
187+
}
188+
}
189+
}
190+
191+
// Array-of-schemas keywords.
192+
foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
193+
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
194+
foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
195+
if ( is_array( $sub_schema ) && ! wp_is_numeric_array( $sub_schema ) ) {
196+
$schema[ $keyword ][ $index ] = _wp_prepare_json_schema_for_client_with_allowed_keywords( $sub_schema, $allowed_keywords );
197+
}
198+
}
199+
}
200+
}
201+
202+
return $schema;
203+
}

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

Lines changed: 2 additions & 162 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,6 @@ class WP_REST_Abilities_V1_List_Controller extends WP_REST_Controller {
3434
*/
3535
protected $rest_base = 'abilities';
3636

37-
/**
38-
* Lookup map of allowed schema keywords for preparing ability schemas in REST responses.
39-
*
40-
* Keyword names are stored as keys so they can be matched with
41-
* array_intersect_key(). Computed lazily on first use and reused while
42-
* preparing nested schemas.
43-
*
44-
* @since 7.1.0
45-
* @var array<string, true>
46-
*/
47-
private array $allowed_schema_keyword_lookup;
48-
4937
/**
5038
* Registers the routes for abilities.
5139
*
@@ -205,154 +193,6 @@ public function get_item_permissions_check( $request ) {
205193
return current_user_can( 'read' );
206194
}
207195

208-
/**
209-
* Determines whether the value is an associative array.
210-
*
211-
* @since 7.1.0
212-
*
213-
* @param mixed $value Value.
214-
* @return bool Whether it is associative array.
215-
*
216-
* @phpstan-assert-if-true array<string, mixed> $value
217-
*/
218-
private function is_associative_array( $value ): bool {
219-
return is_array( $value ) && ! wp_is_numeric_array( $value );
220-
}
221-
222-
/**
223-
* Gets the allowed schema keywords for preparing ability schemas in REST responses.
224-
*
225-
* Uses the fuller draft-04 keyword set, not the smaller REST API subset.
226-
* The published schema is consumed by clients that re-validate values
227-
* against standard draft-04, so it keeps the keywords those validators
228-
* expect.
229-
*
230-
* @since 7.1.0
231-
*
232-
* @return array<string, true> Allowed schema keywords.
233-
*/
234-
private function get_allowed_schema_keywords_for_response(): array {
235-
if ( ! isset( $this->allowed_schema_keyword_lookup ) ) {
236-
$this->allowed_schema_keyword_lookup = array_fill_keys( wp_get_json_schema_allowed_keywords( 'draft-04' ), true );
237-
}
238-
239-
return $this->allowed_schema_keyword_lookup;
240-
}
241-
242-
/**
243-
* Transforms an ability schema for REST response output.
244-
*
245-
* The input and output schemas are a public contract: REST clients (such as
246-
* the `@wordpress/abilities` JS client) consume them as standard JSON Schema
247-
* and validate ability input and output against them. The response must
248-
* therefore use JSON Schema draft-04 forms that standard validators
249-
* understand, not the WordPress-internal conventions that
250-
* `rest_validate_value_from_schema()` also accepts on the server.
251-
*
252-
* Ability schemas may include WordPress-internal properties or unsupported
253-
* schema keywords that should not be exposed in REST responses. This method
254-
* strips keys not recognized by the REST API schema handling. It also
255-
* converts empty array defaults to objects when the schema type is 'object'
256-
* to ensure proper JSON serialization as {} instead of [], and normalizes
257-
* the `required` keyword from the draft-03 per-property boolean form into
258-
* the draft-04 array of property names.
259-
*
260-
* @since 7.1.0
261-
*
262-
* @param array<string, mixed> $schema The schema array.
263-
* @return array<string, mixed> The transformed schema.
264-
*/
265-
private function prepare_schema_for_response( array $schema ): array {
266-
if ( isset( $schema['type'] ) && 'object' === $schema['type'] && isset( $schema['default'] ) ) {
267-
$default = $schema['default'];
268-
if ( is_array( $default ) && empty( $default ) ) {
269-
$schema['default'] = (object) $default;
270-
}
271-
}
272-
273-
$schema = array_intersect_key( $schema, $this->get_allowed_schema_keywords_for_response() );
274-
275-
// Collect draft-03 per-property `required: true` flags into a draft-04
276-
// `required` array of property names on the parent object schema.
277-
//
278-
// This mirrors rest_validate_object_value_from_schema(), where a draft-04
279-
// `required` array takes precedence: when one is present, per-property
280-
// booleans are ignored during validation. They are therefore left out of
281-
// the array here as well (but still stripped from the output) so the
282-
// published schema describes exactly what gets enforced.
283-
if ( isset( $schema['properties'] ) && is_array( $schema['properties'] ) ) {
284-
$has_required_array = isset( $schema['required'] ) && is_array( $schema['required'] );
285-
$required = array();
286-
foreach ( $schema['properties'] as $property => &$property_schema ) {
287-
if ( $this->is_associative_array( $property_schema ) && isset( $property_schema['required'] ) && is_bool( $property_schema['required'] ) ) {
288-
if ( ! $has_required_array && true === $property_schema['required'] ) {
289-
$required[] = (string) $property;
290-
}
291-
unset( $property_schema['required'] );
292-
}
293-
}
294-
unset( $property_schema );
295-
296-
// Property keys are unique, so the collected list needs no deduplication.
297-
// When a draft-04 array is already present, leave it untouched.
298-
if ( ! $has_required_array && count( $required ) > 0 ) {
299-
$schema['required'] = $required;
300-
}
301-
}
302-
303-
// A boolean `required` outside of an object's property list has no draft-04
304-
// equivalent, so drop it rather than emit an invalid keyword.
305-
if ( isset( $schema['required'] ) && is_bool( $schema['required'] ) ) {
306-
unset( $schema['required'] );
307-
}
308-
309-
// Sub-schema maps: keys are user-defined, values are sub-schemas.
310-
// Note: 'dependencies' values can also be property-dependency arrays
311-
// (numeric arrays of strings) which are skipped via wp_is_numeric_array().
312-
foreach ( array( 'properties', 'patternProperties', 'definitions', 'dependencies' ) as $keyword ) {
313-
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
314-
foreach ( $schema[ $keyword ] as $key => $child_schema ) {
315-
if ( $this->is_associative_array( $child_schema ) ) {
316-
$schema[ $keyword ][ $key ] = $this->prepare_schema_for_response( $child_schema );
317-
}
318-
}
319-
}
320-
}
321-
322-
// Single sub-schema keywords.
323-
foreach ( array( 'not', 'additionalProperties', 'additionalItems' ) as $keyword ) {
324-
if ( isset( $schema[ $keyword ] ) && $this->is_associative_array( $schema[ $keyword ] ) ) {
325-
$schema[ $keyword ] = $this->prepare_schema_for_response( $schema[ $keyword ] );
326-
}
327-
}
328-
329-
// Items: single schema or tuple array of schemas.
330-
if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
331-
if ( $this->is_associative_array( $schema['items'] ) ) {
332-
$schema['items'] = $this->prepare_schema_for_response( $schema['items'] );
333-
} else {
334-
foreach ( $schema['items'] as $index => $item_schema ) {
335-
if ( $this->is_associative_array( $item_schema ) ) {
336-
$schema['items'][ $index ] = $this->prepare_schema_for_response( $item_schema );
337-
}
338-
}
339-
}
340-
}
341-
342-
// Array-of-schemas keywords.
343-
foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $keyword ) {
344-
if ( isset( $schema[ $keyword ] ) && is_array( $schema[ $keyword ] ) ) {
345-
foreach ( $schema[ $keyword ] as $index => $sub_schema ) {
346-
if ( $this->is_associative_array( $sub_schema ) ) {
347-
$schema[ $keyword ][ $index ] = $this->prepare_schema_for_response( $sub_schema );
348-
}
349-
}
350-
}
351-
}
352-
353-
return $schema;
354-
}
355-
356196
/**
357197
* Prepares an ability for response.
358198
*
@@ -368,8 +208,8 @@ public function prepare_item_for_response( $ability, $request ) {
368208
'label' => $ability->get_label(),
369209
'description' => $ability->get_description(),
370210
'category' => $ability->get_category(),
371-
'input_schema' => $this->prepare_schema_for_response( $ability->get_input_schema() ),
372-
'output_schema' => $this->prepare_schema_for_response( $ability->get_output_schema() ),
211+
'input_schema' => wp_prepare_json_schema_for_client( $ability->get_input_schema() ),
212+
'output_schema' => wp_prepare_json_schema_for_client( $ability->get_output_schema() ),
373213
'meta' => $ability->get_meta(),
374214
);
375215

tests/phpunit/tests/ai-client/wpAiClientPromptBuilder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2380,6 +2380,8 @@ public function test_using_ability_with_wp_ability_object() {
23802380
$this->assertNotNull( $params );
23812381
$this->assertArrayHasKey( 'properties', $params );
23822382
$this->assertArrayHasKey( 'title', $params['properties'] );
2383+
$this->assertSame( array( 'title' ), $params['required'] );
2384+
$this->assertArrayNotHasKey( 'required', $params['properties']['title'] );
23832385
}
23842386

23852387
/**

0 commit comments

Comments
 (0)