$npx -y skills add Lonsdale201/wp-agent-skills --skill br-routesRegister custom WordPress REST routes with better-route 1.1 Router and RouteBuilder. Use for Router::make or BetterRoute::router, get/post/put/patch/delete/options, permission, protectedByMiddleware, publicRoute, args, route middleware, groups, handler signatures, RequestContext,
| 1 | # better-route: custom REST routes |
| 2 | |
| 3 | Register the complete router during `rest_api_init` and declare the access intent of every route. |
| 4 | |
| 5 | ## Minimal public route |
| 6 | |
| 7 | ```php |
| 8 | use BetterRoute\Http\Response; |
| 9 | use BetterRoute\Router\Router; |
| 10 | |
| 11 | add_action('rest_api_init', static function (): void { |
| 12 | $router = Router::make('myapp', 'v1'); |
| 13 | |
| 14 | $router->get('/ping', static fn (): Response => Response::ok(['pong' => true])) |
| 15 | ->publicRoute(); |
| 16 | |
| 17 | $router->register(); |
| 18 | }); |
| 19 | ``` |
| 20 | |
| 21 | In 1.1 an omitted permission denies every raw route. This applies to `GET`, `HEAD`-style reads registered through the router, writes, and explicit `OPTIONS` routes. |
| 22 | |
| 23 | Choose exactly one intent: |
| 24 | |
| 25 | ```php |
| 26 | // WordPress permission/capability gate. |
| 27 | $router->get('/admin/report', $handler) |
| 28 | ->permission(static fn (): bool => current_user_can('manage_options')); |
| 29 | |
| 30 | // Let middleware authenticate/authorize after WordPress dispatches. |
| 31 | $router->post('/account/orders', $handler) |
| 32 | ->protectedByMiddleware('bearerAuth') |
| 33 | ->middleware([$jwt]); |
| 34 | |
| 35 | // Deliberately anonymous. Also emits OpenAPI security: []. |
| 36 | $router->post('/webhooks/provider', $handler) |
| 37 | ->publicRoute() |
| 38 | ->middleware([$signature]); |
| 39 | ``` |
| 40 | |
| 41 | Do not combine `protectedByMiddleware()` and `permission()` on one route. Both set the WordPress permission callback; the later call replaces the earlier intent. |
| 42 | |
| 43 | ## WordPress route patterns |
| 44 | |
| 45 | Pass WordPress REST regex routes, not framework-style braces: |
| 46 | |
| 47 | ```php |
| 48 | $router->get('/articles/(?P<id>\d+)', $handler) |
| 49 | ->publicRoute() |
| 50 | ->args([ |
| 51 | 'id' => [ |
| 52 | 'required' => true, |
| 53 | 'type' => 'integer', |
| 54 | ], |
| 55 | ]); |
| 56 | ``` |
| 57 | |
| 58 | `/articles/{id}` is an OpenAPI rendering, not a WordPress registration pattern. |
| 59 | |
| 60 | WordPress validates and sanitizes registered `args` before `permission_callback` runs. Keep `validate_callback` and `sanitize_callback` cheap, deterministic, and side-effect free. Perform expensive or authorization-dependent validation in the handler or Resource `writeSchema()`. |
| 61 | |
| 62 | ## Handler argument rules |
| 63 | |
| 64 | Use the signature deliberately: |
| 65 | |
| 66 | ```php |
| 67 | use BetterRoute\Http\RequestContext; |
| 68 | |
| 69 | // Zero parameters. |
| 70 | static fn (): array => ['ok' => true]; |
| 71 | |
| 72 | // One untyped/non-RequestContext parameter receives WP_REST_Request. |
| 73 | static function ($request): array { |
| 74 | return ['id' => (int) $request->get_param('id')]; |
| 75 | } |
| 76 | |
| 77 | // A RequestContext-compatible type receives RequestContext. |
| 78 | static function (RequestContext $context): array { |
| 79 | return ['requestId' => $context->requestId]; |
| 80 | } |
| 81 | |
| 82 | // Two parameters always receive RequestContext, then the WP request. |
| 83 | static function (RequestContext $context, $request): array { |
| 84 | return ['id' => (int) $request->get_param('id')]; |
| 85 | } |
| 86 | ``` |
| 87 | |
| 88 | A union containing `RequestContext` also selects the context for a one-parameter handler. A handler may require at most two parameters. |
| 89 | |
| 90 | Callable forms supported by 1.1 include closures, callable objects, static `[ClassName::class, 'method']` handlers, and instantiable handler classes. If a non-static class handler needs constructor arguments, instantiate it through the plugin container and pass the object; the router will not invent dependencies. |
| 91 | |
| 92 | Return a `BetterRoute\Http\Response`, `WP_REST_Response`, array/scalar, or `WP_Error`. Arrays/scalars become `200` responses. Throw `ApiException` for an intentional normalized error. |
| 93 | |
| 94 | ## Groups and middleware |
| 95 | |
| 96 | ```php |
| 97 | $router->group('/account', static function (Router $router) use ($jwt): void { |
| 98 | $router->middleware([$jwt]); |
| 99 | |
| 100 | $router->get('/me', $me)->protectedByMiddleware('bearerAuth'); |
| 101 | $router->patch('/profile', $update)->protectedByMiddleware('bearerAuth'); |
| 102 | }); |
| 103 | ``` |
| 104 | |
| 105 | Global middleware runs before group middleware, which runs before route middleware. Nested group state is unwound in a `finally` block in 1.1, so an exception while defining one group cannot leak its prefix or middleware into later routes. |
| 106 | |
| 107 | ## CORS preflight |
| 108 | |
| 109 | An explicit preflight route also needs intent: |
| 110 | |
| 111 | ```php |
| 112 | $router->options('/account/profile', static fn () => null) |
| 113 | ->publicRoute() |
| 114 | ->middleware([$cors]); |
| 115 | ``` |
| 116 | |
| 117 | When `CorsMiddleware` is attached, its WordPress bridge can answer a matched preflight before normal dispatch and replace WordPress core CORS headers. See `br-cors-public-client` for the policy rules. |
| 118 | |
| 119 | ## Registration and failure |