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 / SearchEndpoints.php

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

321 lines 8.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Search Endpoints Ability
4 *
5 * Discovers REST API routes by keyword.
6 * Pipeline: search-endpoints → run-rest-request
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 SearchEndpoints
25 */
26 class SearchEndpoints extends Abstract_Ability {
27
28 /**
29 * Default items per page.
30 *
31 * @var int
32 */
33 const DEFAULT_PER_PAGE = 50;
34
35 /**
36 * Maximum items per page.
37 *
38 * @var int
39 */
40 const MAX_PER_PAGE = 100;
41
42 /**
43 * Configure the ability.
44 */
45 public function configure() {
46 $this->id = 'zip-ai/search-endpoints';
47 $this->label = 'Search REST Endpoints';
48 $this->description = 'Discover available REST API routes registered on this site. '
49 . 'Returns route path, HTTP methods, parameters, and capability requirements. '
50 . 'Always call this BEFORE run-rest-request — never guess route names. '
51 . 'Filter by namespace (e.g., "wc/v3", "wp/v2") or HTTP method to narrow results. '
52 . 'Example: search "products" with type=rest to find WooCommerce product endpoints.';
53 $this->capability = 'edit_posts';
54 }
55
56 /**
57 * Get tool type.
58 *
59 * @return string
60 */
61 public function get_tool_type() {
62 return Tool_Types::SEARCH;
63 }
64
65 /**
66 * Get input schema.
67 *
68 * @return array
69 */
70 public function get_input_schema() {
71 return array(
72 'type' => 'object',
73 'properties' => array(
74 'keyword' => array(
75 'type' => 'string',
76 'description' => 'Search term (e.g. "forms", "sureforms", "posts").',
77 ),
78 'type' => array(
79 'type' => 'string',
80 'enum' => array( 'all', 'rest' ),
81 'default' => 'rest',
82 'description' => 'Filter by endpoint type. AJAX discovery is intentionally not exposed.',
83 ),
84 'namespace' => array(
85 'type' => 'string',
86 'description' => 'Filter REST routes by namespace (e.g. "wp/v2", "sureforms/v1").',
87 ),
88 'method' => array(
89 'type' => 'string',
90 'enum' => array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ),
91 'description' => 'Filter REST routes by HTTP method.',
92 ),
93 'per_page' => array(
94 'type' => 'integer',
95 'default' => self::DEFAULT_PER_PAGE,
96 'description' => 'Results per page (max 100).',
97 ),
98 'page' => array(
99 'type' => 'integer',
100 'default' => 1,
101 'description' => 'Page number for pagination.',
102 ),
103 ),
104 'required' => array( 'keyword' ),
105 );
106 }
107
108 /**
109 * Get examples.
110 *
111 * @return array
112 */
113 public function get_examples() {
114 return array(
115 'search endpoints for forms',
116 'find REST routes for posts',
117 'search endpoints sureforms',
118 );
119 }
120
121 // check_permission() intentionally NOT overridden — uses base class
122 // which checks $this->capability ('edit_posts').
123
124 /**
125 * Execute the ability.
126 *
127 * @param array $args Input arguments.
128 * @return array Result array.
129 */
130 public function execute( $args ) {
131 $keyword = sanitize_text_field( $args['keyword'] ?? '' );
132 $namespace_filter = sanitize_text_field( $args['namespace'] ?? '' );
133 $method_filter = strtoupper( sanitize_text_field( $args['method'] ?? '' ) );
134 $per_page = min( absint( $args['per_page'] ?? self::DEFAULT_PER_PAGE ), self::MAX_PER_PAGE );
135 $page = max( 1, absint( $args['page'] ?? 1 ) );
136
137 if ( empty( $keyword ) ) {
138 return Response::error( 'Keyword is required.', 'Provide a search term like "forms", "posts", or "woocommerce".' );
139 }
140
141 $results = $this->search_rest_routes( $keyword, $namespace_filter, $method_filter );
142
143 /**
144 * Filter discovered endpoints before pagination.
145 *
146 * @param array $results All matched endpoints.
147 * @param string $keyword Search keyword.
148 * @param array $args Original arguments.
149 */
150 $results = apply_filters( 'zip_ai_discovered_endpoints', $results, $keyword, $args );
151
152 // Paginate.
153 $total = count( $results );
154 $pages = max( 1, (int) ceil( $total / $per_page ) );
155 $offset = ( $page - 1 ) * $per_page;
156 $paged = array_slice( $results, $offset, $per_page );
157
158 $message = sprintf(
159 'Found %d endpoint(s) matching "%s" (page %d of %d).',
160 $total,
161 $keyword,
162 min( $page, $pages ),
163 $pages
164 );
165
166 return Response::success(
167 $message,
168 array(
169 'endpoints' => $paged,
170 'total' => $total,
171 'page' => min( $page, $pages ),
172 'total_pages' => $pages,
173 'per_page' => $per_page,
174 )
175 );
176 }
177
178 /**
179 * Search REST routes by keyword.
180 *
181 * @param string $keyword Search term.
182 * @param string $namespace_filter Namespace filter.
183 * @param string $method_filter HTTP method filter.
184 * @return array Matching REST endpoints.
185 */
186 private function search_rest_routes( $keyword, $namespace_filter, $method_filter ) {
187 $server = rest_get_server();
188 $routes = $server->get_routes();
189 $namespaces = $server->get_namespaces();
190 $results = array();
191 $keyword_lower = strtolower( $keyword );
192
193 foreach ( $routes as $route_pattern => $handlers ) {
194 // Determine namespace.
195 $route_namespace = '';
196 foreach ( $namespaces as $ns ) {
197 if ( strpos( $route_pattern, '/' . $ns ) === 0 ) {
198 $route_namespace = $ns;
199 break;
200 }
201 }
202
203 if ( ! empty( $namespace_filter ) && $route_namespace !== $namespace_filter ) {
204 continue;
205 }
206
207 $readable_route = $this->humanize_route( $route_pattern );
208 $match_target = strtolower( $route_pattern . ' ' . $route_namespace );
209
210 // Collect methods + check keyword match in param names.
211 $all_methods = array();
212 $param_match = false;
213
214 foreach ( $handlers as $handler ) {
215 if ( ! isset( $handler['methods'] ) ) {
216 continue;
217 }
218
219 $handler_methods = is_array( $handler['methods'] )
220 ? array_keys( $handler['methods'] )
221 : array( $handler['methods'] );
222 $all_methods = array_merge( $all_methods, $handler_methods );
223
224 foreach ( array_keys( $handler['args'] ?? array() ) as $param_name ) {
225 if ( strpos( strtolower( $param_name ), $keyword_lower ) !== false ) {
226 $param_match = true;
227 }
228 }
229 }
230
231 $all_methods = array_unique( $all_methods );
232
233 if ( strpos( $match_target, $keyword_lower ) === false && ! $param_match ) {
234 continue;
235 }
236
237 if ( ! empty( $method_filter ) && ! in_array( $method_filter, $all_methods, true ) ) {
238 continue;
239 }
240
241 $permission = $this->probe_permission( $route_pattern, $handlers, $all_methods );
242 $method_schemas = RouteSchemaBuilder::build_method_schemas( $readable_route, $handlers, $all_methods );
243
244 $results[] = array(
245 'type' => 'rest',
246 'route' => $readable_route,
247 'namespace' => $route_namespace,
248 'methods' => $method_schemas,
249 'permission' => $permission,
250 );
251 }
252
253 return $results;
254 }
255
256 /**
257 * Probe permission for a REST route.
258 *
259 * Constructs a mock WP_REST_Request and calls permission_callback
260 * to report allowed/denied/unknown.
261 *
262 * @param string $route_pattern Route regex pattern.
263 * @param array $handlers Route handlers.
264 * @param array $methods Available methods.
265 * @return string Permission status: allowed, denied, or unknown.
266 */
267 private function probe_permission( $route_pattern, $handlers, $methods ) {
268 // Use the first method available for probing.
269 $probe_method = in_array( 'GET', $methods, true ) ? 'GET' : reset( $methods );
270
271 foreach ( $handlers as $handler ) {
272 if ( ! isset( $handler['permission_callback'] ) ) {
273 continue;
274 }
275
276 // Skip if handler doesn't support our probe method.
277 if ( isset( $handler['methods'] ) && is_array( $handler['methods'] ) ) {
278 if ( ! isset( $handler['methods'][ $probe_method ] ) ) {
279 continue;
280 }
281 }
282
283 $permission_callback = $handler['permission_callback'];
284
285 // Skip non-callable permissions.
286 if ( ! is_callable( $permission_callback ) ) {
287 continue;
288 }
289
290 try {
291 $request = new \WP_REST_Request( $probe_method, $route_pattern );
292 $result = call_user_func( $permission_callback, $request );
293
294 if ( is_wp_error( $result ) ) {
295 return 'denied';
296 }
297
298 return $result ? 'allowed' : 'denied';
299 } catch ( \Exception $e ) {
300 return 'unknown';
301 } catch ( \Error $e ) {
302 return 'unknown';
303 }
304 }
305
306 return 'unknown';
307 }
308
309 /**
310 * Convert route regex patterns to human-readable format.
311 *
312 * Replaces (?P<id>[\d]+) → {id}, etc.
313 *
314 * @param string $route Route pattern.
315 * @return string Human-readable route.
316 */
317 private function humanize_route( $route ) {
318 return preg_replace( '/\(\?P<([^>]+)>[^)]+\)/', '{$1}', $route );
319 }
320 }
321