PluginProbe
ZIP AI – AI Website Builder & AI Agent (Beta) / 0.0.4
ZIP AI – AI Website Builder & AI Agent (Beta) v0.0.4
0.0.10 0.0.9 trunk 0.0.4 0.0.5 0.0.6 0.0.7 0.0.8
zip-ai / classes / core / RouteSchemaBuilder.php

RouteSchemaBuilder.php in ZIP AI – AI Website Builder & AI Agent (Beta) 0.0.4, at classes/core/RouteSchemaBuilder.php

277 lines 7.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Route Schema Builder
4 *
5 * Shared normalizer: converts WP REST route handler args into clean JSON Schema
6 * objects. Used by SearchEndpoints (discovery) and ExecuteRestRequest (execution)
7 * as the single source of truth.
8 *
9 * @package zip-ai
10 */
11
12 namespace ZipAI\Classes\Core;
13
14 if ( ! defined( 'ABSPATH' ) ) {
15 exit;
16 }
17
18 /**
19 * Class RouteSchemaBuilder
20 */
21 class RouteSchemaBuilder {
22
23 /**
24 * JSON Schema keys to preserve. All WP-specific keys
25 * (sanitize_callback, validate_callback, context, arg_options, etc.) are stripped.
26 */
27 private const SCHEMA_KEYS = array(
28 'type',
29 'description',
30 'default',
31 'enum',
32 'items',
33 'properties',
34 'required',
35 'anyOf',
36 'oneOf',
37 'allOf',
38 'format',
39 'pattern',
40 'minimum',
41 'maximum',
42 'minItems',
43 'maxItems',
44 'uniqueItems',
45 'additionalProperties',
46 );
47
48 /**
49 * Build normalized per-method schemas from route handlers.
50 *
51 * Returns: [ 'GET' => [ 'route' => '...', 'params_schema' => {...} ], ... ]
52 *
53 * @param string $route Human-readable route.
54 * @param array $handlers Route handlers from rest_get_server()->get_routes().
55 * @param array $methods All available methods (for ordering).
56 * @return array
57 */
58 public static function build_method_schemas( string $route, array $handlers, array $methods ): array {
59 $schemas = array();
60
61 foreach ( $handlers as $handler ) {
62 if ( ! isset( $handler['methods'] ) ) {
63 continue;
64 }
65
66 $handler_methods = is_array( $handler['methods'] )
67 ? array_keys( $handler['methods'] )
68 : array( $handler['methods'] );
69
70 $properties = array();
71 $required = array();
72
73 foreach ( $handler['args'] ?? array() as $name => $config ) {
74 $properties[ $name ] = self::normalize_arg( $config );
75 if ( ! empty( $config['required'] ) ) {
76 $required[] = $name;
77 }
78 }
79
80 $schema = array(
81 'type' => 'object',
82 'properties' => ! empty( $properties ) ? $properties : new \stdClass(),
83 );
84 if ( ! empty( $required ) ) {
85 $schema['required'] = $required;
86 }
87
88 foreach ( $handler_methods as $m ) {
89 if ( ! isset( $schemas[ $m ] ) ) {
90 $schemas[ $m ] = array(
91 'route' => $route,
92 'params_schema' => $schema,
93 );
94 }
95 }
96 }
97
98 return $schemas;
99 }
100
101 /**
102 * Compute applied defaults for a GET request.
103 *
104 * Returns defaults WordPress silently applies when a param is omitted.
105 * Annotates status=publish with a warning when "any" is a valid value,
106 * so the caller knows drafts are invisible.
107 *
108 * @param string $route REST route (e.g. "/wp/v2/pages").
109 * @param array $explicit_params Params the caller explicitly provided.
110 * @return array
111 */
112 public static function get_applied_defaults( string $route, array $explicit_params ): array {
113 $handler = self::find_route_handler( $route, 'GET' );
114 if ( ! $handler ) {
115 return array();
116 }
117
118 $applied = array();
119
120 foreach ( $handler['args'] ?? array() as $name => $config ) {
121 if ( array_key_exists( $name, $explicit_params ) ) {
122 continue;
123 }
124 if ( ! isset( $config['default'] ) ) {
125 continue;
126 }
127
128 $value = $config['default'];
129
130 // Schema-driven warning: only emit when the schema actually includes
131 // "any" as a valid value — prevents false warnings on custom routes.
132 if ( 'status' === $name && 'publish' === $value ) {
133 $schema = self::normalize_arg( $config );
134 if ( self::schema_includes_any( $schema ) ) {
135 $value = 'publish (WARNING: drafts/pending/private hidden — pass status=any to see all)';
136 }
137 }
138
139 $applied[ $name ] = $value;
140 }
141
142 return $applied;
143 }
144
145 /**
146 * Recursively normalize a WP REST arg config into a clean JSON Schema object.
147 *
148 * Preserves all JSON Schema-relevant keys. Strips WP-specific callbacks
149 * (sanitize_callback, validate_callback, context, arg_options, etc.).
150 *
151 * @param array $config WP REST arg config.
152 * @param int $depth Recursion depth guard (max 6).
153 * @return array
154 */
155 public static function normalize_arg( array $config, int $depth = 0 ): array {
156 if ( $depth > 6 ) {
157 return $config;
158 }
159
160 $out = array();
161
162 foreach ( self::SCHEMA_KEYS as $key ) {
163 if ( ! array_key_exists( $key, $config ) ) {
164 continue;
165 }
166
167 $val = $config[ $key ];
168
169 if ( 'items' === $key && is_array( $val ) ) {
170 $out[ $key ] = self::normalize_arg( $val, $depth + 1 );
171
172 } elseif ( 'properties' === $key && is_array( $val ) ) {
173 $props = array();
174 foreach ( $val as $prop_name => $prop_schema ) {
175 $props[ $prop_name ] = is_array( $prop_schema )
176 ? self::normalize_arg( $prop_schema, $depth + 1 )
177 : $prop_schema;
178 }
179 $out[ $key ] = $props;
180
181 } elseif ( in_array( $key, array( 'anyOf', 'oneOf', 'allOf' ), true ) && is_array( $val ) ) {
182 $d = $depth;
183 $out[ $key ] = array_map(
184 function ( $s ) use ( $d ) {
185 return is_array( $s ) ? self::normalize_arg( $s, $d + 1 ) : $s;
186 },
187 $val
188 );
189
190 } elseif ( 'additionalProperties' === $key && is_array( $val ) ) {
191 $out[ $key ] = self::normalize_arg( $val, $depth + 1 );
192
193 } else {
194 $out[ $key ] = $val;
195 }
196 }
197
198 return $out;
199 }
200
201 /**
202 * Check whether a normalized schema includes "any" as a valid value.
203 *
204 * Handles top-level enum, items.enum (array types), and anyOf/oneOf branches.
205 * Used to gate the status=publish warning to only routes that actually support
206 * status=any (i.e., standard WP content endpoints).
207 *
208 * @param array $schema Normalized schema from normalize_arg().
209 * @return bool
210 */
211 public static function schema_includes_any( array $schema ): bool {
212 // Top-level enum (e.g. string type with enum).
213 if (
214 isset( $schema['enum'] ) &&
215 is_array( $schema['enum'] ) &&
216 in_array( 'any', $schema['enum'], true )
217 ) {
218 return true;
219 }
220
221 // Recurse into items sub-schema (covers items.enum, items.anyOf, etc.).
222 if ( isset( $schema['items'] ) && is_array( $schema['items'] ) ) {
223 if ( self::schema_includes_any( $schema['items'] ) ) {
224 return true;
225 }
226 }
227
228 // Recurse through anyOf / oneOf / allOf combiners.
229 foreach ( array( 'anyOf', 'oneOf', 'allOf' ) as $combiner ) {
230 if ( ! empty( $schema[ $combiner ] ) && is_array( $schema[ $combiner ] ) ) {
231 foreach ( $schema[ $combiner ] as $sub ) {
232 if ( is_array( $sub ) && self::schema_includes_any( $sub ) ) {
233 return true;
234 }
235 }
236 }
237 }
238
239 return false;
240 }
241
242 /**
243 * Find the matching handler for a route + method.
244 *
245 * @param string $route REST route (leading slash required).
246 * @param string $method HTTP method.
247 * @return array|null
248 */
249 public static function find_route_handler( string $route, string $method ): ?array {
250 if ( strpos( $route, '/' ) !== 0 ) {
251 $route = '/' . $route;
252 }
253
254 $server = rest_get_server();
255 $routes = $server->get_routes();
256
257 foreach ( $routes as $pattern => $handlers ) {
258 if ( ! preg_match( '#^' . $pattern . '$#', $route ) ) {
259 continue;
260 }
261 foreach ( $handlers as $handler ) {
262 if ( empty( $handler['methods'] ) ) {
263 continue;
264 }
265 $methods = is_array( $handler['methods'] )
266 ? $handler['methods']
267 : array( $handler['methods'] => true );
268 if ( isset( $methods[ $method ] ) ) {
269 return $handler;
270 }
271 }
272 }
273
274 return null;
275 }
276 }
277