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 / abilities / core / ExecuteRestRequest.php

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

598 lines 16.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Execute REST Request Ability
4 *
5 * Generic REST proxy via rest_do_request(). WordPress handles ALL auth/permissions
6 * via each route's permission_callback.
7 *
8 * @package zip-ai
9 */
10
11 namespace ZipAI\Classes\Abilities\Core;
12
13 use ZipAI\Classes\Abilities\Abstract_Ability;
14 use ZipAI\Classes\Core\RouteSchemaBuilder;
15 use ZipAI\Classes\Core\Tool_Types;
16 use ZipAI\Classes\Core\Response;
17
18 // Exit if accessed directly.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit;
21 }
22
23 /**
24 * Class ExecuteRestRequest
25 */
26 class ExecuteRestRequest extends Abstract_Ability {
27
28 /**
29 * Maximum batch size (matches WP core rest_get_max_batch_size()).
30 *
31 * @var int
32 */
33 const MAX_BATCH_SIZE = 25;
34
35 /**
36 * Maximum response items to prevent context window overflow.
37 *
38 * @var int
39 */
40 const MAX_RESPONSE_ITEMS = 100;
41
42 /**
43 * Is destructive.
44 *
45 * @var bool
46 */
47 protected $is_destructive = true;
48
49 /**
50 * Configure the ability.
51 */
52 public function configure() {
53 $this->id = 'zip-ai/run-rest-request';
54 $this->label = 'Execute REST API Request';
55 $this->description = 'The primary tool for reading WordPress data via the REST API and writing to reviewed non-code-storage routes. '
56 . 'Supports GET broadly, but blocks write requests to user-management and code/content-storage routes '
57 . 'such as users, posts, pages, global styles, templates, template parts, and reusable blocks. '
58 . 'WordPress enforces its own permission_callback per route. '
59 . 'User account create/update/delete routes are blocked; manage users from WordPress admin. '
60 . 'Supports batching up to 25 requests in a single call. '
61 . 'Use search-endpoints to discover route names and required params before calling.';
62 // WordPress REST endpoints enforce their own permission_callback per-route.
63 // This tool is a generic proxy — use edit_posts (same as other zip_ai tools).
64 $this->capability = 'edit_posts';
65 }
66
67 /**
68 * Get tool type.
69 *
70 * @return string
71 */
72 public function get_tool_type() {
73 return Tool_Types::ACTION;
74 }
75
76 /**
77 * Get input schema.
78 *
79 * @return array
80 */
81 public function get_input_schema() {
82 return array(
83 'type' => 'object',
84 'properties' => array(
85 'method' => array(
86 'type' => 'string',
87 'enum' => array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ),
88 'default' => 'GET',
89 'description' => 'HTTP method.',
90 ),
91 'route' => array(
92 'type' => 'string',
93 'description' => 'REST route (e.g. "/wp/v2/posts/42"). No /wp-json prefix.',
94 ),
95 'params' => array(
96 'type' => 'object',
97 'description' => 'Query params for GET, body params for POST/PUT/PATCH/DELETE.',
98 ),
99 'headers' => array(
100 'type' => 'object',
101 'description' => 'Additional HTTP headers.',
102 ),
103 'requests' => array(
104 'type' => 'array',
105 'description' => 'Batch: array of {method, route, params}. Max 25. Ignores top-level method/route/params.',
106 'items' => array(
107 'type' => 'object',
108 'properties' => array(
109 'method' => array( 'type' => 'string' ),
110 'route' => array( 'type' => 'string' ),
111 'params' => array( 'type' => 'object' ),
112 ),
113 ),
114 ),
115 ),
116 'required' => array(),
117 );
118 }
119
120 /**
121 * Get examples.
122 *
123 * @return array
124 */
125 public function get_examples() {
126 return array(
127 'run REST request to get posts',
128 'execute REST API call',
129 'call WordPress REST endpoint',
130 'delete post via REST',
131 'batch REST requests',
132 );
133 }
134
135 // check_permission() intentionally NOT overridden — uses base class
136 // which checks $this->capability ('edit_posts'). Individual REST endpoints
137 // enforce their own permission_callback via rest_do_request().
138
139 /**
140 * Dry run implementation — probes route without executing.
141 *
142 * @param array $args Input arguments.
143 * @return array Result array.
144 */
145 protected function dry_run( $args ) {
146 if ( ! empty( $args['requests'] ) ) {
147 $results = array();
148 $requests = array_slice( $args['requests'], 0, self::MAX_BATCH_SIZE );
149
150 foreach ( $requests as $i => $req ) {
151 $method = strtoupper( sanitize_text_field( $req['method'] ?? 'GET' ) );
152 $route = sanitize_text_field( $req['route'] ?? '' );
153
154 if ( empty( $route ) ) {
155 $results[] = array(
156 'index' => $i,
157 'error' => 'Route is required.',
158 );
159 continue;
160 }
161
162 $results[] = $this->probe_request( $method, $route );
163 }
164
165 return Response::success(
166 sprintf( 'Dry run: probed %d request(s).', count( $results ) ),
167 array(
168 'dry_run' => true,
169 'results' => $results,
170 )
171 );
172 }
173
174 $method = strtoupper( sanitize_text_field( $args['method'] ?? 'GET' ) );
175 $route = sanitize_text_field( $args['route'] ?? '' );
176
177 if ( empty( $route ) ) {
178 return Response::error( 'Route is required.', 'Provide a REST route like "/wp/v2/posts".' );
179 }
180
181 $probe = $this->probe_request( $method, $route );
182
183 return Response::success(
184 sprintf( 'Dry run: %s %s — %s.', $method, $route, $probe['permission'] ?? 'unknown' ),
185 array(
186 'dry_run' => true,
187 'probe' => $probe,
188 )
189 );
190 }
191
192 /**
193 * Execute the ability.
194 *
195 * @param array $args Input arguments.
196 * @return array Result array.
197 */
198 public function execute( $args ) {
199 // Batch mode.
200 if ( ! empty( $args['requests'] ) ) {
201 return $this->execute_batch( $args['requests'] );
202 }
203
204 // Single request mode.
205 $method = strtoupper( sanitize_text_field( $args['method'] ?? 'GET' ) );
206 $route = trim( wp_unslash( $args['route'] ?? '' ) );
207 $params = $args['params'] ?? array();
208 $headers = $args['headers'] ?? array();
209
210 if ( empty( $route ) ) {
211 return Response::error( 'Route is required.', 'Provide a REST route like "/wp/v2/posts". Use search-endpoints to discover routes.' );
212 }
213
214 return $this->execute_single( $method, $route, $params, $headers );
215 }
216
217 /**
218 * Execute a single REST request.
219 *
220 * @param string $method HTTP method.
221 * @param string $route REST route.
222 * @param array $params Request parameters.
223 * @param array $headers Request headers.
224 * @return array Result array.
225 */
226 private function execute_single( $method, $route, $params = array(), $headers = array() ) {
227 /**
228 * Filter whether to allow this REST request.
229 *
230 * Return WP_Error or false to block. Return true to allow.
231 *
232 * @param bool $allowed Whether the request is allowed.
233 * @param string $method HTTP method.
234 * @param string $route REST route.
235 * @param array $params Request parameters.
236 */
237 $allowed = apply_filters( 'zip_ai_allow_rest_request', true, $method, $route, $params );
238
239 if ( is_wp_error( $allowed ) ) {
240 return Response::from_wp_error( $allowed );
241 }
242
243 if ( false === $allowed ) {
244 return Response::error(
245 sprintf( 'Request blocked: %s %s.', $method, $route ),
246 'This request was blocked by a site filter (zip_ai_allow_rest_request).'
247 );
248 }
249
250 // Ensure route starts with /.
251 if ( strpos( $route, '/' ) !== 0 ) {
252 $route = '/' . $route;
253 }
254
255 if ( $this->is_blocked_write_route( $method, $route ) ) {
256 return Response::error(
257 sprintf( 'Request blocked: %s %s.', $method, $route ),
258 'Writes to user-management or code/content-storage REST routes are blocked. Use a dedicated reviewed ability instead.'
259 );
260 }
261
262 // Build WP_REST_Request.
263 $request = new \WP_REST_Request( $method, $route );
264
265 if ( ! empty( $params ) ) {
266 if ( 'GET' === $method ) {
267 $request->set_query_params( $params );
268 } else {
269 $request->set_body_params( $params );
270 $request->set_header( 'Content-Type', 'application/json' );
271 $request->set_body( wp_json_encode( $params ) );
272 }
273 }
274
275 // Set additional headers.
276 if ( ! empty( $headers ) ) {
277 foreach ( $headers as $key => $value ) {
278 $request->set_header( sanitize_text_field( $key ), sanitize_text_field( $value ) );
279 }
280 }
281
282 // Execute via rest_do_request() — WordPress handles permission checks.
283 $response = rest_do_request( $request );
284
285 // Surface defaults that WordPress silently applied — same schema source
286 // as search-endpoints so both tools share the same knowledge.
287 $applied_defaults = ( 'GET' === $method )
288 ? RouteSchemaBuilder::get_applied_defaults( $route, $params )
289 : array();
290
291 return $this->format_response( $response, $method, $route, $applied_defaults );
292 }
293
294 /**
295 * Check if a REST request attempts to mutate user or code/content-storage routes.
296 *
297 * @param string $method HTTP method.
298 * @param string $route REST route.
299 * @return bool
300 */
301 private function is_blocked_write_route( string $method, string $route ): bool {
302 if ( ! in_array( $method, array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) {
303 return false;
304 }
305
306 $route = '/' . ltrim( $route, '/' );
307
308 $blocked_patterns = array(
309 '#^/wp/v2/users(?:/|$)#',
310 '#^/wp/v2/global-styles(?:/|$)#',
311 '#^/wp/v2/templates(?:/|$)#',
312 '#^/wp/v2/template-parts(?:/|$)#',
313 '#^/wp/v2/wp_block(?:/|$)#',
314 '#^/wp/v2/blocks(?:/|$)#',
315 '#^/wp/v2/posts(?:/|$)#',
316 '#^/wp/v2/pages(?:/|$)#',
317 );
318
319 foreach ( $blocked_patterns as $pattern ) {
320 if ( preg_match( $pattern, $route ) ) {
321 return true;
322 }
323 }
324
325 return false;
326 }
327
328 /**
329 * Execute batch REST requests.
330 *
331 * @param array $requests Array of {method, route, params}.
332 * @return array Result array.
333 */
334 private function execute_batch( $requests ) {
335 if ( count( $requests ) > self::MAX_BATCH_SIZE ) {
336 return Response::error(
337 sprintf( 'Batch too large: %d requests (max %d).', count( $requests ), self::MAX_BATCH_SIZE ),
338 'Split into smaller batches of ' . self::MAX_BATCH_SIZE . ' or fewer.'
339 );
340 }
341
342 $results = array();
343 $succeeded = 0;
344 $failed = 0;
345
346 foreach ( $requests as $i => $req ) {
347 $method = strtoupper( sanitize_text_field( $req['method'] ?? 'GET' ) );
348 $route = sanitize_text_field( $req['route'] ?? '' );
349 $params = $req['params'] ?? array();
350
351 if ( empty( $route ) ) {
352 $results[] = array(
353 'index' => $i,
354 'method' => $method,
355 'route' => '',
356 'success' => false,
357 'error' => 'Route is required.',
358 );
359 ++$failed;
360 continue;
361 }
362
363 $result = $this->execute_single( $method, $route, $params );
364
365 $results[] = array(
366 'index' => $i,
367 'method' => $method,
368 'route' => $route,
369 ) + $result;
370
371 if ( ! empty( $result['success'] ) ) {
372 ++$succeeded;
373 } else {
374 ++$failed;
375 }
376 }
377
378 $total = count( $requests );
379
380 return Response::success(
381 sprintf( 'Batch complete: %d/%d succeeded, %d failed.', $succeeded, $total, $failed ),
382 array(
383 'results' => $results,
384 'total' => $total,
385 'succeeded' => $succeeded,
386 'failed' => $failed,
387 )
388 );
389 }
390
391 /**
392 * Probe a REST request without executing.
393 *
394 * Uses route pattern matching against registered routes and probes
395 * the permission_callback directly.
396 *
397 * @param string $method HTTP method.
398 * @param string $route REST route.
399 * @return array Probe result.
400 */
401 private function probe_request( $method, $route ) {
402 // Ensure route starts with /.
403 if ( strpos( $route, '/' ) !== 0 ) {
404 $route = '/' . $route;
405 }
406
407 $server = rest_get_server();
408 $routes = $server->get_routes();
409
410 // Find matching route handler by testing regex patterns.
411 $matched_handler = null;
412 foreach ( $routes as $route_pattern => $handlers ) {
413 $regex = '#^' . $route_pattern . '$#';
414 if ( ! preg_match( $regex, $route ) ) {
415 continue;
416 }
417
418 // Find handler that supports the requested method.
419 foreach ( $handlers as $handler ) {
420 if ( ! isset( $handler['methods'] ) ) {
421 continue;
422 }
423 $handler_methods = is_array( $handler['methods'] ) ? $handler['methods'] : array( $handler['methods'] => true );
424 if ( isset( $handler_methods[ $method ] ) ) {
425 $matched_handler = $handler;
426 break 2;
427 }
428 }
429 }
430
431 if ( ! $matched_handler ) {
432 return array(
433 'method' => $method,
434 'route' => $route,
435 'exists' => false,
436 'permission' => 'unknown',
437 'error' => 'No matching route found.',
438 );
439 }
440
441 // Probe permission.
442 $permission = 'unknown';
443 if ( isset( $matched_handler['permission_callback'] ) && is_callable( $matched_handler['permission_callback'] ) ) {
444 try {
445 $request = new \WP_REST_Request( $method, $route );
446 $perm_result = call_user_func( $matched_handler['permission_callback'], $request );
447 if ( is_wp_error( $perm_result ) ) {
448 $permission = 'denied';
449 } else {
450 $permission = $perm_result ? 'allowed' : 'denied';
451 }
452 } catch ( \Exception $e ) {
453 $permission = 'unknown';
454 } catch ( \Error $e ) {
455 $permission = 'unknown';
456 }
457 }
458
459 return array(
460 'method' => $method,
461 'route' => $route,
462 'exists' => true,
463 'permission' => $permission,
464 );
465 }
466
467 /**
468 * Format a WP_REST_Response for output.
469 *
470 * Extracts pagination headers and truncates large arrays.
471 *
472 * @param \WP_REST_Response $response REST response.
473 * @param string $method HTTP method.
474 * @param string $route REST route.
475 * @param array $applied_defaults Defaults WordPress silently applied.
476 * @return array Formatted result.
477 */
478 private function format_response( $response, $method, $route, $applied_defaults = array() ) {
479 $status = $response->get_status();
480 $data = $response->get_data();
481
482 // Extract pagination headers.
483 $headers = $response->get_headers();
484 $pagination = array();
485
486 if ( isset( $headers['X-WP-Total'] ) ) {
487 $pagination['total'] = (int) $headers['X-WP-Total'];
488 }
489 if ( isset( $headers['X-WP-TotalPages'] ) ) {
490 $pagination['total_pages'] = (int) $headers['X-WP-TotalPages'];
491 }
492
493 // Handle error responses.
494 if ( $status >= 400 ) {
495 $error_message = 'Request failed.';
496 $suggestion = '';
497
498 if ( is_array( $data ) ) {
499 if ( isset( $data['message'] ) ) {
500 $error_message = $data['message'];
501 }
502 if ( isset( $data['code'] ) ) {
503 if ( 'rest_forbidden' === $data['code'] ) {
504 $suggestion = 'Permission denied. The current user lacks the required capability for this endpoint.';
505 } elseif ( 'rest_no_route' === $data['code'] ) {
506 $suggestion = 'This route does not exist. Use search-endpoints to find the actual registered routes for this plugin — do NOT guess endpoint names.';
507 }
508 }
509 }
510
511 $result = array(
512 'success' => false,
513 'error' => sprintf( '%s %s → %d: %s', $method, $route, $status, $error_message ),
514 'status_code' => $status,
515 );
516
517 if ( ! empty( $suggestion ) ) {
518 $result['suggestion'] = $suggestion;
519 }
520
521 return $result;
522 }
523
524 // Suspicious 2xx with empty/null data on a write request — treat as failure.
525 // Real CREATE/UPDATE/DELETE endpoints return the affected entity or { id, ... }.
526 // An empty 200 on POST/PUT/PATCH/DELETE almost always means the handler bailed silently.
527 $is_write = in_array( strtoupper( $method ), array( 'POST', 'PUT', 'PATCH', 'DELETE' ), true );
528 $is_empty = ( null === $data || '' === $data || ( is_array( $data ) && empty( $data ) ) );
529 if ( $is_write && $is_empty ) {
530 return array(
531 'success' => false,
532 'error' => sprintf(
533 '%s %s → %d but response body is empty. The endpoint accepted the request but returned nothing — the handler likely failed silently or the route does not actually create what you expected.',
534 $method,
535 $route,
536 $status
537 ),
538 'status_code' => $status,
539 'suggestion' => 'Verify by GET-ing the entity you tried to create. If it does not exist, the route is wrong. Use search-endpoints + read plugin source code to find the correct create endpoint.',
540 );
541 }
542
543 // Truncate large array responses.
544 $truncated = false;
545 if ( is_array( $data ) && ! $this->is_assoc( $data ) && count( $data ) > self::MAX_RESPONSE_ITEMS ) {
546 $total_items = count( $data );
547 $data = array_slice( $data, 0, self::MAX_RESPONSE_ITEMS );
548 $truncated = true;
549 }
550
551 $result_data = array(
552 'status_code' => $status,
553 'data' => $data,
554 );
555
556 if ( ! empty( $pagination ) ) {
557 $result_data['pagination'] = $pagination;
558 }
559
560 if ( $truncated ) {
561 $result_data['truncated'] = true;
562 $result_data['truncated_message'] = sprintf(
563 'Response truncated to %d items (total: %d). Use pagination params (per_page, page) to get more.',
564 self::MAX_RESPONSE_ITEMS,
565 $total_items
566 );
567 }
568
569 if ( ! empty( $applied_defaults ) ) {
570 $result_data['applied_defaults'] = $applied_defaults;
571 }
572
573 $message = sprintf( '%s %s → %d OK.', $method, $route, $status );
574 if ( ! empty( $applied_defaults ) ) {
575 $parts = array();
576 foreach ( $applied_defaults as $key => $val ) {
577 $parts[] = $key . '=' . ( is_string( $val ) ? $val : wp_json_encode( $val ) );
578 }
579 $message .= ' Note: defaults applied: ' . implode( ', ', $parts ) . '.';
580 }
581
582 return Response::success( $message, $result_data );
583 }
584
585 /**
586 * Check if an array is associative.
587 *
588 * @param array $arr Array to check.
589 * @return bool True if associative.
590 */
591 private function is_assoc( $arr ) {
592 if ( empty( $arr ) ) {
593 return false;
594 }
595 return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
596 }
597 }
598