| 1 |
<?php |
| 2 |
/** |
| 3 |
* Abilities input normalizer. |
| 4 |
* |
| 5 |
* Makes a no-input GET to a readonly WP Ability validate as an empty object. |
| 6 |
* |
| 7 |
* @package zip-ai |
| 8 |
*/ |
| 9 |
|
| 10 |
namespace ZipAI\MCP\Classes\Core; |
| 11 |
|
| 12 |
defined( 'ABSPATH' ) || exit; |
| 13 |
|
| 14 |
class Abilities_Input_Normalizer { |
| 15 |
|
| 16 |
/** |
| 17 |
* Hook the core `wp_ability_normalize_input` filter. |
| 18 |
* |
| 19 |
* @return void |
| 20 |
*/ |
| 21 |
public static function init(): void { |
| 22 |
add_filter( 'wp_ability_normalize_input', array( __CLASS__, 'default_empty_object' ), 10, 3 ); |
| 23 |
} |
| 24 |
|
| 25 |
/** |
| 26 |
* Supply an empty object as the input when a readonly ability is called with |
| 27 |
* none. |
| 28 |
* |
| 29 |
* WHY: core `WP_Ability::normalize_input()` only substitutes an input when the |
| 30 |
* ability's `input_schema` declares a top-level `default`. An ability whose |
| 31 |
* schema is an OBJECT with no `default` and no `required` fields omits it — |
| 32 |
* notably Astra's `astra/list-font-family` (an object schema, every field |
| 33 |
* optional). The browser Abilities run-route can't carry a JSON object on a GET |
| 34 |
* (query params are strings; `?input={}` arrives as the string "{}"), so with no |
| 35 |
* `input` param the ability receives `null` and the route rejects it 400 "input |
| 36 |
* is not of type object" — even though `{}` is perfectly valid. The ZIP AI chat |
| 37 |
* calls such abilities over exactly that GET route, and we can't patch a stock |
| 38 |
* theme's schema, so we supply the empty object here. (Abilities with an EMPTY |
| 39 |
* schema — `astra/get-font-heading` / `get-font-body` return `array()` — need no |
| 40 |
* help: they fall out at the `empty( $schema )` guard below, no object to satisfy.) |
| 41 |
* |
| 42 |
* SCOPE: this feature needs it only for `zipai/*` and `astra/*` abilities, so it |
| 43 |
* is gated on that namespace prefix — the filter fires for every ability on the |
| 44 |
* site, but we no-op for anyone else's rather than widen their input handling. |
| 45 |
* Beyond that: only `null` input, and only schemas where `{}` is genuinely valid — |
| 46 |
* `type: object`, no `default` already (core handled those), and no `required` |
| 47 |
* fields. An ability that truly needs input keeps its `required[]` and is still |
| 48 |
* rejected by `validate_input()`, so this loosens nothing real; it only makes |
| 49 |
* currently-uncallable no-input GET abilities callable. |
| 50 |
* |
| 51 |
* @param mixed $input The (possibly null) normalized input. |
| 52 |
* @param string $name The ability name. |
| 53 |
* @param mixed $ability The WP_Ability instance. |
| 54 |
* @return mixed The input, or an empty array when an empty object is the right default. |
| 55 |
*/ |
| 56 |
public static function default_empty_object( $input, $name, $ability ) { |
| 57 |
if ( null !== $input || ! $ability instanceof \WP_Ability ) { |
| 58 |
return $input; |
| 59 |
} |
| 60 |
|
| 61 |
// Only widen the abilities this feature actually calls over the run-route — |
| 62 |
// never every plugin's abilities site-wide. |
| 63 |
if ( 0 !== strpos( $name, 'zipai/' ) && 0 !== strpos( $name, 'astra/' ) ) { |
| 64 |
return $input; |
| 65 |
} |
| 66 |
|
| 67 |
$schema = $ability->get_input_schema(); |
| 68 |
if ( empty( $schema ) ) { |
| 69 |
return $input; |
| 70 |
} |
| 71 |
|
| 72 |
// Only when `{}` is a valid input: object-typed, no default already |
| 73 |
// substituted by core, and no required fields. |
| 74 |
$is_object = isset( $schema['type'] ) && 'object' === $schema['type']; |
| 75 |
$has_default = array_key_exists( 'default', $schema ); |
| 76 |
$requires = ! empty( $schema['required'] ); |
| 77 |
|
| 78 |
if ( $is_object && ! $has_default && ! $requires ) { |
| 79 |
return array(); |
| 80 |
} |
| 81 |
|
| 82 |
return $input; |
| 83 |
} |
| 84 |
} |
| 85 |
|