From e988b337d0640a2b98842a2ef3576cb86f74de62 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:23:39 +0600 Subject: [PATCH 1/8] refactor(routing)!: overhaul routing system with pending routes and middleware pipeline Introduce PendingRoute, Router, RouteGroup, and ResourceRegistration to support deferred registration and named routes. Add Pipeline and Macroable trait for middleware orchestration and runtime macro support. Refactor Route and Ajax to register and resolve PendingRoute instances, build final route paths with parameter constraints, and handle responses via Response and WP_REST_Response. Update Middleware to use Pipeline and accept WP_REST_Request. Initialize REST and AJAX registration via RouteServiceProvider. Add Router utilities for URL generation and route clearing. Include integration and unit test scaffolding, plus AuthMiddleware test helper. BREAKING CHANGE: routing registration, middleware execution flow, and response handling APIs have been restructured. --- README.md | 484 ++++++++++++++------ src/Ajax.php | 129 ++++-- src/Contracts/Middleware.php | 27 +- src/DataBinder.php | 40 +- src/Middleware.php | 77 +++- src/PendingRoute.php | 172 +++++++ src/Pipeline.php | 98 ++++ src/Providers/RouteServiceProvider.php | 58 ++- src/ResourceRegistration.php | 99 ++++ src/Response.php | 33 +- src/Route.php | 348 +++++++++++--- src/RouteGroup.php | 53 +++ src/Router.php | 160 +++++++ src/Traits/Macroable.php | 80 ++++ tests/Integration/AuthMiddleware.php | 18 + tests/Integration/RouteIntegrationTest.php | 390 ++++++++++++++++ tests/Integration/SampleIntegrationTest.php | 12 - tests/Integration/TestController.php | 44 ++ tests/Unit/PendingRouteTest.php | 37 ++ tests/Unit/RouterTest.php | 41 ++ tests/Unit/SampleTest.php | 12 - 21 files changed, 2104 insertions(+), 308 deletions(-) create mode 100644 src/PendingRoute.php create mode 100644 src/Pipeline.php create mode 100644 src/ResourceRegistration.php create mode 100644 src/RouteGroup.php create mode 100644 src/Router.php create mode 100644 src/Traits/Macroable.php create mode 100644 tests/Integration/AuthMiddleware.php create mode 100644 tests/Integration/RouteIntegrationTest.php delete mode 100644 tests/Integration/SampleIntegrationTest.php create mode 100644 tests/Integration/TestController.php create mode 100644 tests/Unit/PendingRouteTest.php create mode 100644 tests/Unit/RouterTest.php delete mode 100644 tests/Unit/SampleTest.php diff --git a/README.md b/README.md index d2c5468..a938ddc 100644 --- a/README.md +++ b/README.md @@ -13,131 +13,24 @@ One of the key features of WpMVC Routing is its support for middleware. Middlewa By using WpMVC Routing in your WordPress plugin, you can easily create custom routes and middleware to handle a wide variety of requests, including AJAX requests, with ease. This makes it an excellent tool for developing modern and dynamic WordPress plugins that require advanced routing capabilities and additional security measures. - [About WpMVC Routing](#about-wpmvc-routing) - - [Requirement](#requirement) - - [Methods structure](#methods-structure) - - [Installation](#installation) - - [Configuration](#configuration) + - [Installation](#installation) - [Register Routes In Route File](#register-routes-in-route-file) - [Rest Route](#rest-route) - [Write your first route](#write-your-first-route) - [With Controller](#with-controller) + - [Other HTTP Methods](#other-http-methods) - [Dynamic Routing](#dynamic-routing) + - [Regular Expression Constraints](#regular-expression-constraints) + - [Named Routes](#named-routes) - [Route Grouping](#route-grouping) - [Resource Controller](#resource-controller) - [Actions Handled By Resource Controller](#actions-handled-by-resource-controller) - [Ajax Route](#ajax-route) - - [Get Api Endpoint](#get-api-endpoint) - [Middleware](#middleware) + - [Requirement & Configuration (Standalone Usage)](#requirement--configuration-standalone-usage) - [License](#license) -## Requirement - -WpMVC routing requires a dependency injection (DI) container. We do not use any hard-coded library, so you can choose to use any DI library you prefer. However, it is important to follow our DI structure, which includes having the `set` and `get` methods in your DI container. - -We recommend using [symfony/dependency-injection](https://symfony.com/doc/current/components/dependency_injection.html) as it already has these 3 methods implemented in the package. - -### Methods structure -Here is the structure of the methods that your DI container should have in order to work with WpMVC routing: - -1. `set` method - - ```php - /** - * Define an object or a value in the container. - * - * @param string $name Entry name - * @param mixed $value Value, define objects - */ - public function set(string $name, $value) {} - ``` -2. `get` method - - ```php - - /** - * Returns an entry of the container by its name. - * - * @template T - * @param string|class-string $name Entry name or a class name. - * - * @return mixed|T - */ - public function get($name) {} - ``` -## Installation - -``` -composer require wpmvc/routing -``` -## Configuration -1. Your plugin must include a `routes` folder. This folder will contain all of your plugin's route files. - -2. Within the `routes` folder, create two subfolders: `ajax` and `rest`. These folders will contain your plugin's route files for AJAX and REST requests, respectively. - -3. If you need to support different versions of your routes, you can create additional files within the `ajax` and `rest` subfolders. For example, you might create `v1.php` and `v2.php` files within the `ajax` folder to support different versions of your AJAX routes. - -4. Folder structure example: - ``` - routes: - rest: - api.php - v1.php - v2.php - ajax: - api.php - v1.php - ``` -5. In your `RouteServiceProvider` class, set the necessary properties for your route system. This includes setting the `rest and ajax namespaces`, the versions of your routes, any middleware you want to use, and the directory where your route files are located. Here's an example: - ```php - - [ - 'namespace' => 'myplugin', - 'versions' => ['v1', 'v2'] - ], - 'ajax' => [ - 'namespace' => 'myplugin', - 'versions' => [] - ], - 'middleware' => [], - 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' - ]; - - parent::boot(); - } - } - - ``` -6. Finally, execute the `boot` method of your `RouteServiceProvider` class using the `init` action hook, like so: - - ```php - add_action('init', function() { - $route_service_provider = new \MyPlugin\Providers\RouteServiceProvider; - $route_service_provider->boot(); - }); - ``` -That's it! Your plugin is now configured with WpMVC Routing system, and you can start creating your own routes and handling requests with ease. - ## Register Routes In Route File ### Rest Route @@ -160,6 +53,12 @@ Route::get('user', function(WP_REST_Request $wp_rest_request) { ``` In this example, we're using the `get()` method of the Route class to define a `GET request` to the /user endpoint. The closure passed as the second argument returns a response using the `Response::send()` method, which takes an array of data to be returned in `JSON format`. + +You may also pass a custom HTTP status code and an array of custom headers to `Response::send()`: + +```php +return Response::send(['error' => 'Not Found'], 404, ['X-Custom-Header' => 'Value']); +``` #### With Controller If you prefer to use a controller for your route logic, you can specify the controller and method as an array, as shown below: @@ -168,6 +67,28 @@ Route::get('user', [UserController::class, 'index']); ``` Here, we're using the `get()` method of the Route class to define a `GET request` to the /user endpoint. We're specifying the controller class and method as an array, where `UserController::class` refers to the class name and `index` is the method name. +#### Other HTTP Methods +Along with `get`, WpMVC Routing provides methods for all common HTTP verbs: `post`, `put`, `patch`, and `delete`. + +```php +Route::post('user', [UserController::class, 'store']); +Route::put('user/{id}', [UserController::class, 'update']); +Route::patch('user/{id}', [UserController::class, 'update']); +Route::delete('user/{id}', [UserController::class, 'destroy']); +``` + +If you need a route to respond to multiple verbs, you can use the `match` method. Or, if you want a route to respond to all HTTP verbs, use the `any` method: + +```php +Route::match(['GET', 'POST'], '/', function () { + // +}); + +Route::any('/', function () { + // +}); +``` + #### Dynamic Routing You can use dynamic routing to handle requests to endpoints with dynamic parameters. To define a route with a required parameter, use curly braces around the parameter name, as shown below: @@ -182,8 +103,82 @@ To define a route with an optional parameter, you can add a question mark after // Optional id Route::get('users/{id?}', [UserController::class, 'index']); ``` + +#### Regular Expression Constraints +You may constrain the format of your route parameters using the `where` method on a route instance. The `where` method accepts the name of the parameter and a regular expression defining how the parameter should be constrained: + +```php +Route::get('users/{name}', [UserController::class, 'show'])->where('name', '[A-Za-z]+'); + +Route::get('users/{id}', [UserController::class, 'show'])->where('id', '[0-9]+'); +``` + +You can constrain multiple parameters at once by passing an array to the `wheres` method: + +```php +Route::get('users/{id}/{name}', [UserController::class, 'show'])->wheres([ + 'id' => '[0-9]+', + 'name' => '[a-z]+' +]); +``` + +#### Route Prefixing +You may prefix the URI of an individual route by chaining the `prefix` method onto the route definition: + +```php +Route::get('profile', [UserProfileController::class, 'show'])->prefix('user'); +// Matches: /user/profile +``` + +#### Named Routes +Named routes allow the convenient generation of URLs to specific routes. You may specify a name for a route by chaining the `name` method onto the route definition: + +```php +// routes/rest/api.php or routes/ajax/api.php +Route::get('user/profile', [UserProfileController::class, 'show'])->name('profile'); + +// With parameters... +Route::get('user/{id}/profile', [UserProfileController::class, 'edit'])->name('profile.edit'); +``` + +Once you have assigned a name to a given route, you may use the route's name when generating URLs via the `Router::url` method: + +```php +// Inside a Controller method or route callback +use WpMVC\Routing\Router; + +// Generating URLs... +$url = Router::url('profile'); + +$url = Router::url('profile.edit', ['id' => 1]); +``` + +> [!IMPORTANT] +> `Router::url()` will only work inside route callbacks (such as inside a Controller method or a route closure). If you need to generate an API URL outside of a route callback, you should use the native WordPress `get_rest_url()` function as demonstrated below. + +#### Get Api Endpoint + +The `get_rest_url()` function can be used to get the REST API endpoint for the current site. To use this function, you need to provide the current site ID and the `namespace` for your plugin. + +```php +$site_id = get_current_blog_id(); +$namespace = 'myplugin'; + +$rest_route_path = get_rest_url($site_id, $namespace); + +$user_rest_route = $rest_route_path . '/user'; +``` + +Similarly, you can use the `get_site_url()` function to get the URL of the current site, and then append your namespace to it to create the AJAX API endpoint URL. + +```php +$ajax_route_path = get_site_url($site_id) . '/' . $namespace; + +$user_ajax_route = $ajax_route_path . '/user'; +``` + #### Route Grouping -You can group related routes together using the `group()` method. This allows you to apply middleware or other attributes to multiple routes at once. You can create `nested groups` as well, as shown below: +You can group related routes together using the `group()` method. This allows you to apply attributes such as prefixes and middleware to multiple routes at once without defining them on each route. You can create `nested groups` as well, as shown below: ```php Route::group('admin', function() { @@ -197,7 +192,7 @@ Route::group('admin', function() { Route::patch('/{id}', [UserController::class, 'update']); Route::delete('/{id}', [UserController::class, 'delete']); } ); -} ); +} )->middleware('admin'); ``` #### Resource Controller @@ -219,8 +214,39 @@ Resource routing automatically generates the typical CRUD routes for your contro | PATCH | /users/{user} | update | | DELETE | /users/{user} | delete | +If you only need to bind a portion of the resource actions to a controller, you can use the `only` and `except` methods: + +```php +Route::resource('user', UserController::class)->only(['index', 'show']); + +Route::resource('user', UserController::class)->except(['store', 'update', 'delete']); +``` + +You can also chain `middleware()` and `name()` directly onto the resource registration to apply them to all generated routes: + +```php +Route::resource('user', UserController::class)->middleware('auth')->name('users'); +``` + With resource routing, you don't have to define each route separately. Instead, you can handle all of the CRUD operations in a single controller class, making it easier to organize your code and keep your routes consistent. +#### Route Macros +The `Route` class uses the `Macroable` trait, which allows you to easily add your own custom methods to the router. Once a macro is defined, you can use it just like any other method on the `Route` class. + +```php +use WpMVC\Routing\Route; +use WpMVC\Routing\Response; + +Route::macro('status', function (string $uri) { + return Route::get($uri, function () { + return Response::send(['status' => 'OK']); + }); +}); + +// Usage +Route::status('system/status'); +``` + ### Ajax Route `routes/ajax/api.php` @@ -241,30 +267,11 @@ Ajax::get('user', function(WP_REST_Request $wp_rest_request) { To route to `WordPress admin`, your route must use a middleware with the name `admin`. If you apply this middleware to your Ajax route, WpMVC will load the WP admin code. Check out the [Middleware Docs](#middleware) to see the middleware use process. -### Get Api Endpoint - -The `get_rest_url()` function can be used to get the REST API endpoint for the current site. To use this function, you need to provide the current site ID and the `namespace` for your plugin. - -```php -$site_id = get_current_blog_id(); -$namespace = 'myplugin'; - -$rest_route_path = get_rest_url($site_id, $namespace); - -$user_rest_route = $rest_route_path . '/user'; -``` - -Similarly, you can use the `get_site_url()` function to get the URL of the current site, and then append your namespace to it to create the AJAX API endpoint URL. - -```php -$ajax_route_path = get_site_url($site_id) . '/' . $namespace; - -$user_ajax_route = $ajax_route_path . '/user'; -``` - ## Middleware -To create a middleware, you need to implement the `Middleware` interface. The `handle` method of the middleware must return a boolean value. If the handle method returns `false`, the request will be stopped immediately. +To create a middleware, you need to implement the `Middleware` interface. The `handle` method of the middleware will receive the current `WP_REST_Request` and a `$next` closure representing the pipeline payload. + +If the handle method returns `false`, or a `WP_Error`, the pipeline stops immediately. Returning `true` or calling `$next($request)` allows the request to continue. Here is an example of creating a middleware class named `EnsureIsUserAdmin`: @@ -273,41 +280,216 @@ Here is an example of creating a middleware class named `EnsureIsUserAdmin`: namespace MyPlugin\App\Http\Middleware; +defined( 'ABSPATH' ) || exit; + use WpMVC\Routing\Contracts\Middleware; use WP_REST_Request; +use WP_Error; class EnsureIsUserAdmin implements Middleware { /** - * Handle an incoming request. - * - * @param WP_REST_Request $wp_rest_request - * @return bool - */ - public function handle( WP_REST_Request $wp_rest_request ): bool + * Handle an incoming request. + * + * @param WP_REST_Request $wp_rest_request The current request instance. + * @param mixed $next The next middleware closure in the stack. + * @return bool|WP_Error Returns true to continue, false to forbid, or WP_Error. + */ + public function handle( WP_REST_Request $wp_rest_request, $next ) { - return current_user_can( 'manage_options' ); + if ( ! current_user_can( 'manage_options' ) ) { + return false; + } + + return $next($wp_rest_request); } } ``` -Once you have created the middleware, you need to register it in the RouteServiceProvider [Configuration](#configuration). You can do this by adding the middleware class to the `middleware` array in the `$properties` array of the RouteServiceProvider class. +Once you have created the middleware, you need to register it in your `config/app.php` file. You can do this by adding the middleware class to the `middleware` array. ```php -parent::$properties = [ - ... - 'middleware' => [ - 'admin' => \MyPlugin\App\Http\Middleware\EnsureIsUserAdmin::class - ] +// config/app.php +return [ + // ... + 'middleware' => [ + 'admin' => \MyPlugin\App\Http\Middleware\EnsureIsUserAdmin::class + ] ]; ``` -To use the middleware in a route, you can add the middleware name as the last argument of the route definition, as shown below: +To use the middleware in a route, you can chain the `middleware` method onto the route definition. You may assign multiple middleware by passing an array of names: ```php -Route::get('/admin', [AdminController::class, 'index'], ['admin']); +// Assign single middleware +Route::get('/admin', [AdminController::class, 'index'])->middleware('admin'); + +// Assign multiple middleware +Route::get('/admin', [AdminController::class, 'index'])->middleware(['admin', 'auth']); ``` +## Requirement & Configuration (Standalone Usage) + +> [!NOTE] +> If you are using WpMVC, these configurations and requirements are already handled for you by the framework. The following instructions are only necessary if you are integrating the `wpmvc/routing` package independently into another WordPress plugin ecosystem. + +### Requirement + +WpMVC routing requires a dependency injection (DI) container. We do not use any hard-coded library, so you can choose to use any DI library you prefer. However, it is important to follow our DI structure, which includes having the `set` and `get` methods in your DI container. + +We recommend using [wpmvc/container](https://github.com/wpmvc/container) as it already has these 2 methods implemented in the package and supports PHP 7.4 to 8.5. + +#### Methods structure +Here is the structure of the methods that your DI container should have in order to work with WpMVC routing: + +1. `set` method + + ```php + /** + * Define an object or a value in the container. + * + * @param string $name Entry name + * @param mixed $value Value, define objects + */ + public function set(string $name, $value) {} + ``` +2. `get` method + + ```php + + /** + * Returns an entry of the container by its name. + * + * @template T + * @param string|class-string $name Entry name or a class name. + * + * @return mixed|T + */ + public function get($name) {} + ``` + +### Configuration +1. Your plugin must include a `routes` folder. This folder will contain all of your plugin's route files. + +2. Within the `routes` folder, create two subfolders: `ajax` and `rest`. These folders will contain your plugin's route files for AJAX and REST requests, respectively. + +3. If you need to support different versions of your routes, you can create additional files within the `ajax` and `rest` subfolders. For example, you might create `v1.php` and `v2.php` files within the `ajax` folder to support different versions of your AJAX routes. + +4. Folder structure example: + ``` + routes: + rest: + api.php + v1.php + v2.php + ajax: + api.php + v1.php + ``` +5. In your `RouteServiceProvider` class, set the necessary properties for your route system. This includes setting the `rest and ajax namespaces`, the versions of your routes, any middleware you want to use, and the directory where your route files are located. Here's an example: + ```php + + [ + 'namespace' => 'myplugin', + 'versions' => ['v1', 'v2'] + ], + 'ajax' => [ + 'namespace' => 'myplugin', + 'versions' => [] + ], + 'middleware' => [], + 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' + ]; + + parent::boot(); + } + } + + ``` + +7. (Optional) You can define global hooks to intercept REST API responses and permissions across all routes. Add these to your `$properties` array: + + ```php + parent::$properties = [ + 'rest' => [ + 'namespace' => 'myplugin', + 'versions' => ['v1'] + ], + 'ajax' => [ + 'namespace' => 'myplugin', + 'versions' => [] + ], + // Global hooks (optional) + 'rest_response_action_hook' => 'myplugin_rest_response_action', + 'rest_response_filter_hook' => 'myplugin_rest_response_filter', + 'rest_permission_filter_hook' => 'myplugin_rest_permission_filter', + 'middleware' => [], + 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' + ]; + ``` + + * `rest_response_action_hook`: Fires an action before the response is returned. Passes `WP_REST_Request $request` and `string $final_route`. + * `rest_response_filter_hook`: Filters the final response data. Passes `mixed $response`, `WP_REST_Request $request`, and `string $final_route`. + * `rest_permission_filter_hook`: Filters the boolean/WP_Error result of the middleware pipeline. Passes `bool|WP_Error $permission`, `array $middleware`, and `string $final_route`. + + **Example hook usage:** + + ```php + // Action Hook Example + add_action('myplugin_rest_response_action', function (\WP_REST_Request $request, string $final_route) { + // Log the request + error_log("Route {$final_route} accessed."); + }, 10, 2); + + // Response Filter Example + add_filter('myplugin_rest_response_filter', function ($response, \WP_REST_Request $request, string $final_route) { + // Modify the response data + if (is_array($response)) { + $response['timestamp'] = time(); + } + return $response; + }, 10, 3); + + // Permission Filter Example + add_filter('myplugin_rest_permission_filter', function ($permission, array $middleware, string $final_route) { + // E.g. Deny access if a specific option is enabled + if (get_option('myplugin_maintenance_mode')) { + return new \WP_Error('maintenance', 'Site is under maintenance', ['status' => 503]); + } + return $permission; + }, 10, 3); + ``` + +8. Finally, execute the `boot` method of your `RouteServiceProvider` class using the `init` action hook, like so: + + ```php + add_action('init', function() { + $route_service_provider = new \MyPlugin\Providers\RouteServiceProvider; + $route_service_provider->boot(); + }); + ``` +That's it! Your plugin is now configured with WpMVC Routing system, and you can start creating your own routes and handling requests with ease. + ## License WpMVC Routing is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). diff --git a/src/Ajax.php b/src/Ajax.php index ada0271..cb0ef27 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -1,4 +1,11 @@ get_details(); + $method = $details['methods']; + //phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated if ( $method !== $_SERVER['REQUEST_METHOD'] ) { return; } - $route = static::get_final_route( $route ); - $middleware = array_merge( static::$group_middleware, $middleware ); + $final_route = static::get_final_route_path( $details['uri'], $details['prefix'] ); + $middleware = $details['middleware']; + $callback = $details['callback']; global $wp; - /** - * @var WP $wp - */ + /** @var WP $wp */ - $match = preg_match( '@^' . $route . '$@i', rtrim( '/' . $wp->request, '/' ), $matches ); + $match = preg_match( '@^' . $final_route . '$@i', rtrim( '/' . $wp->request, '/' ), $matches ); if ( ! $match ) { return; @@ -36,14 +91,24 @@ protected static function register_route( string $method, string $route, $callba static::$route_found = true; + $url_params = []; + + foreach ( $matches as $param => $value ) { + if ( ! is_int( $param ) ) { + $url_params[ $param ] = $value; + } + } + /** * Fire admin init if the current API has admin middleware */ static::admin_init( $middleware ); - $is_allowed = Middleware::is_user_allowed( $middleware ); + $request = static::get_wp_rest_request( $method, $url_params ); - if ( ! $is_allowed ) { + $is_allowed = Middleware::is_user_allowed( $middleware, $request ); + + if ( is_wp_error( $is_allowed ) || ! $is_allowed ) { status_header( 401 ); Response::set_headers( [] ); echo wp_json_encode( @@ -52,29 +117,28 @@ protected static function register_route( string $method, string $route, $callba 'message' => 'Sorry, you are not allowed to do that.' ] ); - exit; - } - - $url_params = []; - - foreach ( $matches as $param => $value ) { - if ( ! is_int( $param ) ) { - $url_params[ $param ] = $value; + if ( static::$should_exit ) { + exit; } + return; } - static::bind_wp_rest_request( $method, $url_params ); - $response = static::callback( $callback ); echo wp_json_encode( $response ); - exit; + + if ( static::$should_exit ) { + exit; + } } /** - * Fire admin init if the current API has admin middleware + * Initialize WordPress administration APIs if required by middleware. + * + * @param array $middleware The list of middleware for the route. + * @return void */ - protected static function admin_init( array $middleware ) { + protected static function admin_init( array $middleware ): void { if ( ! in_array( 'admin', $middleware ) ) { return; @@ -91,10 +155,17 @@ protected static function admin_init( array $middleware ) { do_action( 'admin_init' ); } - protected static function bind_wp_rest_request( string $method, array $url_params = [] ) { + /** + * Create a WP_REST_Request object populated with current request data. + * + * @param string $method The HTTP request method. + * @param array $url_params Parameters extracted from the URL. + * @return \WP_REST_Request The populated request object. + */ + protected static function get_wp_rest_request( string $method, array $url_params = [] ): \WP_REST_Request { - $wp_rest_request = new WP_REST_Request( $method, '/' ); - $wp_rest_server = new WP_REST_Server; + $wp_rest_request = new \WP_REST_Request( $method, '/' ); + $wp_rest_server = new \WP_REST_Server; $wp_rest_request->set_url_params( $url_params ); //phpcs:ignore WordPress.Security.NonceVerification.Recommended @@ -105,6 +176,8 @@ protected static function bind_wp_rest_request( string $method, array $url_param $wp_rest_request->set_headers( $wp_rest_server->get_headers( wp_unslash( $_SERVER ) ) ); $wp_rest_request->set_body( $wp_rest_server->get_raw_data() ); - RouteServiceProvider::$container->set( WP_REST_Request::class, $wp_rest_request ); + RouteServiceProvider::$container->set( \WP_REST_Request::class, $wp_rest_request ); + + return $wp_rest_request; } } \ No newline at end of file diff --git a/src/Contracts/Middleware.php b/src/Contracts/Middleware.php index 7d5c41b..113eff1 100644 --- a/src/Contracts/Middleware.php +++ b/src/Contracts/Middleware.php @@ -1,4 +1,11 @@ namespace = $namespace; } - public function set_version( string $version ) { + /** + * Set the active version for subsequent routes. + * + * @param string $version The API version (e.g., 'v1'). + * @return void + */ + public function set_version( string $version ): void { $this->version = $version; } + /** + * Retrieve the currently active namespace. + * + * @return string + */ public function get_namespace(): string { return $this->namespace; } + /** + * Retrieve the currently active version. + * + * @return string + */ public function get_version(): string { return $this->version; } diff --git a/src/Middleware.php b/src/Middleware.php index 93e4232..d24d588 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -1,47 +1,78 @@ get( $current_middleware ); - - if ( ! $middleware_object instanceof MiddlewareContract ) { - return false; - } + if ( null === $wp_rest_request ) { + $wp_rest_request = RouteServiceProvider::$container->get( \WP_REST_Request::class ); + } - $permission = $container->call( [$middleware_object, 'handle'] ); + $pipes = []; - if ( $permission instanceof WP_Error || ! $permission ) { - return $permission; + foreach ( $middleware as $name ) { + if ( isset( static::$middleware[$name] ) ) { + $pipes[] = static::$middleware[$name]; } } - - return $default_permission; + + if ( empty( $pipes ) ) { + return $default_permission; + } + + $pipeline = new Pipeline(); + + return $pipeline->send( $wp_rest_request ) + ->through( $pipes ) + ->then( + function ( $request ) use ( $default_permission ) { + return $default_permission; + } + ); } } diff --git a/src/PendingRoute.php b/src/PendingRoute.php new file mode 100644 index 0000000..0178a37 --- /dev/null +++ b/src/PendingRoute.php @@ -0,0 +1,172 @@ +methods = $methods; + $this->uri = $uri; + $this->callback = $callback; + $this->middleware = $middleware; + } + + /** + * Assign middleware to the route. + * + * @param array|string $middleware The middleware name(s). + * @return $this Returns the current instance for method chaining. + */ + public function middleware( $middleware ): self { + if ( is_string( $middleware ) ) { + $middleware = [ $middleware ]; + } + + $this->middleware = array_merge( $this->middleware, $middleware ); + + return $this; + } + + /** + * Assign a unique name to the route. + * + * @param string $name The unique name. + * @return $this Returns the current instance for method chaining. + */ + public function name( string $name ): self { + $this->name = $name; + + Router::add_named_route( $name, $this ); + + return $this; + } + + /** + * Assign a prefix to the route URI. + * + * @param string $prefix The URI prefix. + * @return $this Returns the current instance for method chaining. + */ + public function prefix( string $prefix ): self { + $this->prefix = $prefix; + + return $this; + } + + /** + * Add a regex constraint for a route parameter. + * + * @param string $name The parameter name. + * @param string $expression The regex pattern. + * @return $this Returns the current instance for method chaining. + */ + public function where( string $name, string $expression ): self { + $this->wheres[$name] = $expression; + + return $this; + } + + /** + * Add multiple regex constraints for route parameters. + * + * @param array $wheres An associative array of parameter names and expressions. + * @return $this Returns the current instance for method chaining. + */ + public function wheres( array $wheres ): self { + $this->wheres = array_merge( $this->wheres, $wheres ); + + return $this; + } + + /** + * Retrieve the route configuration details. + * + * @return array An associative array containing methods, uri, callback, middleware, name, prefix, and wheres. + */ + public function get_details(): array { + return [ + 'methods' => $this->methods, + 'uri' => $this->uri, + 'callback' => $this->callback, + 'middleware' => $this->middleware, + 'name' => $this->name, + 'prefix' => $this->prefix, + 'wheres' => $this->wheres, + ]; + } +} diff --git a/src/Pipeline.php b/src/Pipeline.php new file mode 100644 index 0000000..a7c2b86 --- /dev/null +++ b/src/Pipeline.php @@ -0,0 +1,98 @@ +passable = $passable; + + return $this; + } + + /** + * Set the list of pipes (middleware) to pass the object through. + * + * @param array $pipes An array of middleware class names or callables. + * @return $this Returns the current instance for method chaining. + */ + public function through( array $pipes ): self { + $this->pipes = $pipes; + + return $this; + } + + /** + * Run the pipeline with a final destination closure. + * + * @param Closure $destination The final closure to execute after all pipes. + * @return mixed The result of the pipeline execution. + */ + public function then( Closure $destination ) { + $pipeline = array_reduce( + array_reverse( $this->pipes ), + $this->carry(), + $destination + ); + + return $pipeline( $this->passable ); + } + + /** + * Get a closure that represents a single slice of the pipeline. + * + * @return Closure + */ + protected function carry(): Closure { + return function ( $stack, $pipe ) { + return function ( $passable ) use ( $stack, $pipe ) { + if ( is_callable( $pipe ) ) { + return $pipe( $passable, $stack ); + } + + $container = Providers\RouteServiceProvider::$container; + $middleware = $container->get( $pipe ); + + return $container->call( [$middleware, 'handle'], [$passable, $stack] ); + }; + }; + } +} diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 1163032..4e9ba9d 100644 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -1,4 +1,11 @@ request ) || 1 !== preg_match( "@^" . static::$properties['ajax']['namespace'] . "/(.*)/?@i", $wp->request ) ) { return; } @@ -47,17 +70,30 @@ public function action_ajax_api_init( WP $wp ) { } /** - * Fires when preparing to serve a REST API request. + * Initialize REST API routes. + * + * @return void */ public function action_rest_api_init(): void { static::init_routes( 'rest' ); } - public static function get_properties() { - return static::$properties; + /** + * Retrieve the current configuration properties. + * + * @return array + */ + public static function get_properties(): array { + return static::$properties ?? []; } - protected static function init_routes( string $type ) { + /** + * Scan and initialize route files. + * + * @param string $type The routing context ('rest' or 'ajax'). + * @return void + */ + protected static function init_routes( string $type ): void { Middleware::set_middleware_list( static::$properties['middleware'] ); $data_binder = static::$container->get( DataBinder::class ); @@ -79,5 +115,11 @@ protected static function init_routes( string $type ) { } } } + + if ( 'rest' === $type ) { + Route::register_all( 'rest' ); + } else { + Ajax::register_all( 'ajax' ); + } } } \ No newline at end of file diff --git a/src/ResourceRegistration.php b/src/ResourceRegistration.php new file mode 100644 index 0000000..0777aac --- /dev/null +++ b/src/ResourceRegistration.php @@ -0,0 +1,99 @@ +routes = $routes; + } + + /** + * Restrict the resource to only the specified actions. + * + * @param array $items The actions to keep (e.g., ['index', 'show']). + * @return $this Returns the current instance for method chaining. + */ + public function only( array $items ): self { + foreach ( $this->routes as $method => $route ) { + if ( ! in_array( $method, $items ) ) { + Router::remove_route( $route ); + unset( $this->routes[$method] ); + } + } + + return $this; + } + + /** + * Exclude specific actions from the resource registration. + * + * @param array $items The actions to remove. + * @return $this Returns the current instance for method chaining. + */ + public function except( array $items ): self { + foreach ( $this->routes as $method => $route ) { + if ( in_array( $method, $items ) ) { + Router::remove_route( $route ); + unset( $this->routes[$method] ); + } + } + + return $this; + } + + /** + * Assign middleware to all routes in the current resource. + * + * @param array|string $middleware The middleware name(s). + * @return $this Returns the current instance for method chaining. + */ + public function middleware( $middleware ): self { + foreach ( $this->routes as $route ) { + $route->middleware( $middleware ); + } + + return $this; + } + + /** + * Assign a base name for the resource routes. + * + * @param string $name The base name. + * @return $this Returns the current instance for method chaining. + */ + public function name( string $name ): self { + // Resource naming is usually done per route, + // but this allows for a base name if needed. + return $this; + } +} diff --git a/src/Response.php b/src/Response.php index 46f9351..d1ffc2d 100644 --- a/src/Response.php +++ b/src/Response.php @@ -1,17 +1,46 @@ $callback ) { - static::resource( $resource, $callback, [], $middleware ); + static::resource( $resource, $callback ); } } - public static function resource( string $route, $callback, array $take = [], array $middleware = [] ) { + /** + * Define a resource route. + * + * @param string $route + * @param mixed $callback + * @return ResourceRegistration + */ + public static function resource( string $route, $callback ): ResourceRegistration { $routes = [ 'index' => [ 'method' => 'GET', @@ -81,55 +190,100 @@ public static function resource( string $route, $callback, array $take = [], arr ], ]; - if ( ! empty( $take ) ) { - if ( isset( $take['type'] ) && 'only' === $take['type'] ) { - $routes = array_intersect_key( $routes, array_flip( $take['items'] ) ); - } else { - $routes = array_diff_key( $routes, array_flip( $take['items'] ) ); - } + $pending_routes = []; + foreach ( $routes as $callback_method => $args ) { + $pending_routes[$callback_method] = static::register_route( $args['method'], $args['route'], [$callback, $callback_method] ); } - foreach ( $routes as $callback_method => $args ) { - static::register_route( $args['method'], $args['route'], [$callback, $callback_method], $middleware ); + return new ResourceRegistration( $pending_routes ); + } + + /** + * Internal method to register a route by adding it to the Router. + * + * @param string|array $method + * @param string $route + * @param mixed $callback + * @return PendingRoute + */ + protected static function register_route( $method, string $route, $callback ): PendingRoute { + $pending_route = new PendingRoute( $method, $route, $callback ); + + // Inherit group prefix and middleware + $pending_route->prefix( static::$route_prefix ); + $pending_route->middleware( static::$group_middleware ); + + Router::add_route( $pending_route ); + + return $pending_route; + } + + /** + * Dispatch and register all routes with WordPress. + * + * @param string $type The type of route (rest or ajax). + * @return void + */ + public static function register_all( string $type = 'rest' ) { + foreach ( Router::get_routes() as $route ) { + if ( 'rest' === $type ) { + static::register_with_wordpress_rest( $route ); + } } + + // Clear routes after registration to avoid duplicate registration in multifile environments + Router::clear_pending(); } - protected static function register_route( string $method, string $route, $callback, array $middleware = [] ) { + /** + * Physically register a PendingRoute with the WordPress REST API. + * + * @param PendingRoute $route The route instance to register. + * @return void + */ + protected static function register_with_wordpress_rest( PendingRoute $route ) { + $details = $route->get_details(); + $data_binder = RouteServiceProvider::$container->get( DataBinder::class ); $namespace = $data_binder->get_namespace(); - $full_route = static::get_final_route( $route ); - $middleware = array_merge( static::$group_middleware, $middleware ); + + $callback = $details['callback']; + $final_route = static::get_final_route_path( $details['uri'], $details['prefix'], true, $details['wheres'] ); + $middleware = $details['middleware']; + $method = $details['methods']; rest_get_server()->register_route( - $namespace, $full_route, [ + $namespace, $final_route, [ [ 'methods' => $method, - 'callback' => function( WP_REST_Request $wp_rest_request ) use( $callback, $full_route ) { + 'callback' => function( WP_REST_Request $wp_rest_request ) use( $callback, $final_route ) { RouteServiceProvider::$container->set( WP_REST_Request::class, $wp_rest_request ); $properties = RouteServiceProvider::get_properties(); if ( ! empty( $properties['rest_response_action_hook'] ) ) { - do_action( $properties['rest_response_action_hook'], $wp_rest_request, $full_route ); + do_action( $properties['rest_response_action_hook'], $wp_rest_request, $final_route ); } if ( ! empty( $properties['rest_response_filter_hook'] ) ) { - return apply_filters( $properties['rest_response_filter_hook'], static::callback( $callback ), $wp_rest_request, $full_route ); + return apply_filters( $properties['rest_response_filter_hook'], static::callback( $callback ), $wp_rest_request, $final_route ); } return static::callback( $callback ); }, - 'permission_callback' => function() use( $middleware, $full_route ) { - $permission = Middleware::is_user_allowed( $middleware ); + 'permission_callback' => function( \WP_REST_Request $wp_rest_request ) use( $middleware, $final_route ) { + $permission = Middleware::is_user_allowed( $middleware, $wp_rest_request ); $properties = RouteServiceProvider::get_properties(); if ( ! empty( $properties['rest_permission_filter_hook'] ) ) { - $permission = apply_filters( $properties['rest_permission_filter_hook'], $permission, $middleware, $full_route ); + $permission = apply_filters( $properties['rest_permission_filter_hook'], $permission, $middleware, $final_route ); } - if ( $permission instanceof WP_Error ) { - static::set_status_code( $permission->get_error_code() ); + if ( $permission instanceof \WP_Error ) { + $data = $permission->get_error_data(); + $status = isset( $data['status'] ) ? $data['status'] : 500; + static::set_status_code( (int) $status ); } return $permission; } @@ -138,27 +292,47 @@ protected static function register_route( string $method, string $route, $callba ); } + /** + * Handle the callback execution and response transformation. + * + * Resolves dependencies via the container and prepares the response. + * + * @param mixed $callback The route callback. + * @return mixed The processed response data or WP_REST_Response. + */ protected static function callback( $callback ) { try { - $response = RouteServiceProvider::$container->call( $callback ); + $request = RouteServiceProvider::$container->get( \WP_REST_Request::class ); + $params = $request ? $request->get_url_params() : []; + + $response = RouteServiceProvider::$container->call( $callback, $params ); + + // If it's the structure from Response::send() + if ( is_array( $response ) && isset( $response['status_code'] ) && array_key_exists( 'data', $response ) ) { + $status_code = intval( $response['status_code'] ); + $data = $response['data']; + + // If in REST context, wrap in WP_REST_Response to ensure status is respected + if ( class_exists( '\WP_REST_Response' ) && ( defined( 'REST_REQUEST' ) || RouteServiceProvider::$container->get( \WP_REST_Request::class ) ) ) { + return new \WP_REST_Response( $data, $status_code ); + } - if ( ! is_array( $response ) ) { - exit; + static::set_status_code( $status_code ); + $data = $response['data']; + } else { + // Otherwise, treat the entire response as data with 200 status + $status_code = 200; + static::set_status_code( $status_code ); + $data = $response; } - $status_code = intval( $response['status_code'] ); - static::set_status_code( $status_code ); - - $response = $response['data']; - - if ( $status_code > 399 && 600 > $status_code ) { - $response['data']['status'] = $status_code; - return $response; - } + return $data; - return $response; - } catch ( Exception $ex ) { + } catch ( \Exception $ex ) { $status_code = intval( $ex->getCode() ); + if ( $status_code < 100 || $status_code > 599 ) { + $status_code = 500; + } static::set_status_code( $status_code ); $response = [ @@ -183,10 +357,17 @@ protected static function callback( $callback ) { $response['message'] = 'Something went wrong.'; } } + return $response; } } + /** + * Set the HTTP status code and register a filter to ensure it's honored. + * + * @param int $status_code The HTTP status code to set. + * @return void + */ protected static function set_status_code( int $status_code ) { status_header( $status_code ); /** @@ -202,13 +383,25 @@ protected static function set_status_code( int $status_code ) { ); } - protected static function get_final_route( string $route ) { - if ( ! empty( static::$route_prefix ) ) { - $route = rtrim( static::$route_prefix, '/' ) . '/' . ltrim( $route, '/' ); + /** + * Construct the final route path including namespace, version, and prefix. + * + * @param string $route The base route URI. + * @param string $prefix Optional prefix for the route. + * @param bool $format_regex Whether to convert parameters to regex. + * @param array $wheres Optional regex constraints for parameters. + * @return string The fully qualified route path. + */ + public static function get_final_route_path( string $route, string $prefix = '', bool $format_regex = true, array $wheres = [] ) { + if ( ! empty( $prefix ) ) { + $route = rtrim( $prefix, '/' ) . '/' . ltrim( $route, '/' ); } $route = trim( $route, '/' ); - $route = static::format_route_regex( $route ); + + if ( $format_regex ) { + $route = static::format_route_regex( $route, $wheres ?? [] ); + } $data_binder = RouteServiceProvider::$container->get( DataBinder::class ); $namespace = $data_binder->get_namespace(); @@ -220,7 +413,14 @@ protected static function get_final_route( string $route ) { return "/{$namespace}/{$route}"; } - protected static function format_route_regex( string $route ): string { + /** + * Format route parameters into regex patterns. + * + * @param string $route The route URI containing parameters. + * @param array $wheres Optional parameter constraints. + * @return string The route URI with parameters converted to regex. + */ + protected static function format_route_regex( string $route, array $wheres = [] ): string { if ( strpos( $route, '}' ) === false ) { return $route; } @@ -228,23 +428,43 @@ protected static function format_route_regex( string $route ): string { preg_match_all( '#\{(.*?)\}#', $route, $params ); if ( strpos( $route, '?}' ) !== false ) { - return static::optional_param( $route, $params ); + return static::optional_param( $route, $params, $wheres ); } else { - return static::required_param( $route, $params ); + return static::required_param( $route, $params, $wheres ); } } - protected static function optional_param( string $route, array $params ): string { + /** + * Handle optional parameter formatting. + * + * @param string $route The route URI. + * @param array $params The matched parameters. + * @param array $wheres Optional constraints. + * @return string + */ + protected static function optional_param( string $route, array $params, array $wheres = [] ): string { foreach ( $params[0] as $key => $value ) { - $route = str_replace( '/' . $value, '(?:/(?P<' . str_replace( '?', '', $params[1][$key] ) . '>[-\w]+))?', $route ); + $param_name = str_replace( '?', '', $params[1][$key] ); + $regex = $wheres[$param_name] ?? '[-\w]+'; + $route = str_replace( '/' . $value, '(?:/(?P<' . $param_name . '>' . $regex . '))?', $route ); } return $route; } - protected static function required_param( string $route, array $params ): string { + /** + * Handle required parameter formatting. + * + * @param string $route The route URI. + * @param array $params The matched parameters. + * @param array $wheres Optional constraints. + * @return string + */ + protected static function required_param( string $route, array $params, array $wheres = [] ): string { foreach ( $params[0] as $key => $value ) { - $route = str_replace( $value, '(?P<' . $params[1][$key] . '>[-\w]+)', $route ); + $param_name = $params[1][$key]; + $regex = $wheres[$param_name] ?? '[-\w]+'; + $route = str_replace( $value, '(?P<' . $param_name . '>' . $regex . ')', $route ); } return $route; diff --git a/src/RouteGroup.php b/src/RouteGroup.php new file mode 100644 index 0000000..11fa999 --- /dev/null +++ b/src/RouteGroup.php @@ -0,0 +1,53 @@ +routes = $routes; + } + + /** + * Assign middleware to all routes within the group. + * + * @param array|string $middleware The middleware name(s). + * @return $this Returns the current instance for method chaining. + */ + public function middleware( $middleware ): self { + foreach ( $this->routes as $route ) { + $route->middleware( $middleware ); + } + + return $this; + } +} diff --git a/src/Router.php b/src/Router.php new file mode 100644 index 0000000..3d126f5 --- /dev/null +++ b/src/Router.php @@ -0,0 +1,160 @@ + $r ) { + if ( $r === $route ) { + unset( static::$routes[$key] ); + static::$routes = array_values( static::$routes ); + break; + } + } + } + + /** + * Map a unique name to a route instance. + * + * @param string $name The unique name. + * @param PendingRoute $route The route instance. + * @return void + */ + public static function add_named_route( string $name, PendingRoute $route ) { + static::$named_routes[ $name ] = $route; + } + + /** + * Get all registered routes. + * + * @return PendingRoute[] An array of registered route instances. + */ + public static function get_routes(): array { + return static::$routes; + } + + /** + * Retrieve a route instance by its unique name. + * + * @param string $name The name of the route. + * @return PendingRoute|null The route instance if found, otherwise null. + */ + public static function get_named_route( string $name ): ?PendingRoute { + return static::$named_routes[ $name ] ?? null; + } + + /** + * Generate a fully qualified URL for a named route. + * + * @param string $name The name of the route. + * @param array $parameters Optional parameters to replace in the URI. + * @return string The generated URL, or an empty string if required parameters are missing. + */ + public static function url( string $name, array $parameters = [] ): string { + $route = static::get_named_route( $name ); + + if ( ! $route ) { + return ''; + } + + $details = $route->get_details(); + + // Use the Route class to get the final path (including namespace/version/prefix) + $uri = Route::get_final_route_path( $details['uri'], $details['prefix'], false ); + + // Replace required parameters {param} + foreach ( $parameters as $key => $value ) { + $uri = str_replace( '{' . $key . '}', $value, $uri ); + } + + // Replace optional parameters {param?} + foreach ( $parameters as $key => $value ) { + $uri = str_replace( '{' . $key . '?}', $value, $uri ); + } + + // Check for UNRESOLVED required parameters + if ( preg_match( '/\{[^\?\}]+\}/', $uri ) ) { + // Some required parameters were not replaced. + return ''; + } + + // Remove any remaining optional parameter placeholder + $uri = preg_replace( '/\/\{[^\}]+\?\}/', '', $uri ); + + return rest_url( $uri ); + } + + /** + * Clear the registry. + * + * @param bool $clear_routes + * @param bool $clear_named_routes + * @return void + */ + public static function clear( bool $clear_routes = true, bool $clear_named_routes = true ) { + if ( $clear_routes ) { + static::$routes = []; + } + if ( $clear_named_routes ) { + static::$named_routes = []; + } + } + + /** + * Clear only pending routes from the collection. + * + * Useful after routes have been physically registered with WordPress. + * + * @return void + */ + public static function clear_pending() { + static::clear( true, false ); + } +} diff --git a/src/Traits/Macroable.php b/src/Traits/Macroable.php new file mode 100644 index 0000000..e9cb636 --- /dev/null +++ b/src/Traits/Macroable.php @@ -0,0 +1,80 @@ +get_param( 'token' ) === 'secret' ) { + return $next( $wp_rest_request ); + } + + return new WP_Error( 'rest_forbidden', 'Forbidden', ['status' => 403] ); + } +} diff --git a/tests/Integration/RouteIntegrationTest.php b/tests/Integration/RouteIntegrationTest.php new file mode 100644 index 0000000..a06d39c --- /dev/null +++ b/tests/Integration/RouteIntegrationTest.php @@ -0,0 +1,390 @@ +container = new class { + protected $instances = []; + + public function get( $id ) { + if ( ! isset( $this->instances[$id] ) && class_exists( $id ) ) { + $this->instances[$id] = new $id(); + } + return $this->instances[$id] ?? null; + } + + public function set( $id, $instance ) { + $this->instances[$id] = $instance; } + + public function call( $callback, array $args = [] ) { + if ( is_array( $callback ) ) { + $class = is_object( $callback[0] ) ? $callback[0] : $this->get( $callback[0] ); + $method = $callback[1]; + $ref = new \ReflectionMethod( $class, $method ); + } else { + $ref = new \ReflectionFunction( $callback ); + } + + $resolved = []; + foreach ( $ref->getParameters() as $param ) { + $name = $param->getName(); + $type = $param->getType(); + $type_name = ( $type && ! $type->isBuiltin() ) ? $type->getName() : null; + + // 1. Try by name + if ( isset( $args[$name] ) ) { + $resolved[] = $args[$name]; + unset( $args[$name] ); + continue; + } + + // 2. Try by type from args + if ( $type_name ) { + foreach ( $args as $key => $val ) { + if ( is_object( $val ) && is_a( $val, $type_name ) ) { + $resolved[] = $val; + unset( $args[$key] ); + continue 2; + } + } + + // 3. Try by type from container + if ( $type_name === 'WP_REST_Request' ) { + $resolved[] = $this->get( 'WP_REST_Request' ); + continue; + } + $instance = $this->get( $type_name ); + if ( $instance ) { + $resolved[] = $instance; + continue; + } + } + + // 4. Positional fallback for builtin/untyped + if ( ! empty( $args ) ) { + $resolved[] = array_shift( $args ); + continue; + } + + // 5. Default value + if ( $param->isDefaultValueAvailable() ) { + $resolved[] = $param->getDefaultValue(); + } else { + $resolved[] = null; + } + } + + if ( is_array( $callback ) ) { + return $ref->invokeArgs( is_object( $callback[0] ) ? $callback[0] : $this->get( $callback[0] ), $resolved ); + } + return $ref->invokeArgs( $resolved ); + } + }; + + $data_binder = new DataBinder(); + $data_binder->set_namespace( 'wpmvc' ); + $this->container->set( DataBinder::class, $data_binder ); + + RouteServiceProvider::$container = $this->container; + } + + /** + * Test that routes are registered correctly in WordPress. + */ + public function test_route_registration_in_wordpress() { + Route::get( + 'test-route', function() { + return Response::send( ['success' => true] ); + } + )->name( 'test.route' ); + + // Trigger deferred registration + Route::register_all( 'rest' ); + + $request = new WP_REST_Request( 'GET', '/wpmvc/test-route' ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( ['success' => true], $response->get_data() ); + } + + /** + * Test named route URL generation using the real rest_url(). + */ + public function test_named_route_url_generation() { + Route::get( 'users/{id}', function() { return 'ok'; } )->name( 'user.show' ); + + $url = Router::url( 'user.show', ['id' => 123] ); + + $this->assertTrue( + strpos( $url, '/wpmvc/users/123' ) !== false || strpos( $url, 'rest_route=/wpmvc/users/123' ) !== false, + "URL does not contain expected route path: $url" + ); + } + + /** + * Test route groups with prefixes and nested registration. + */ + public function test_route_groups_integration() { + Route::group( + 'v1', function() { + Route::get( 'ping', function() { return 'pong'; } )->name( 'v1.ping' ); + } + ); + + Route::register_all( 'rest' ); + + $request = new WP_REST_Request( 'GET', '/wpmvc/v1/ping' ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'pong', $response->get_data() ); + } + + /** + * Test Resource routing registration. + */ + public function test_resource_routing() { + Route::resource( 'posts', TestController::class ); + Route::register_all( 'rest' ); + + // Test index (GET /posts) + $request = new WP_REST_Request( 'GET', '/wpmvc/posts' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'index', $response->get_data() ); + + // Test store (POST /posts) + $request = new WP_REST_Request( 'POST', '/wpmvc/posts' ); + $response = rest_do_request( $request ); + $this->assertEquals( 201, $response->get_status() ); + $this->assertEquals( 'store', $response->get_data() ); + + // Test show (GET /posts/123) + $request = new WP_REST_Request( 'GET', '/wpmvc/posts/123' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + $this->assertEquals( '123', $data['id'] ); + $this->assertTrue( $data['has_request'] ); + } + + /** + * Test Controller Injection and parameter order. + */ + public function test_dynamic_parameter_injection_order() { + Route::get( 'posts/{post_id}/comments/{comment_id}', [TestController::class, 'multipart'] ); + Route::register_all( 'rest' ); + + $request = new WP_REST_Request( 'GET', '/wpmvc/posts/10/comments/20' ); + $response = rest_do_request( $request ); + + $this->assertEquals( 200, $response->get_status() ); + $data = $response->get_data(); + + // We need to verify that Route::callback is actually passing parameters to the container. + // Currently it's NOT. This test is expected to FAIL until we update Route.php. + $this->assertEquals( '10', $data['1'], "First parameter (post_id) should be '10'" ); + $this->assertEquals( '20', $data['2'], "Second parameter (comment_id) should be '20'" ); + } + + /** + * Test Middleware enforcement. + */ + public function test_middleware_enforcement() { + // Register middleware in the provider list + \WpMVC\Routing\Middleware::set_middleware_list( + [ + 'auth' => AuthMiddleware::class + ] + ); + + Route::get( 'secure', function() { return Response::send( 'secret-data' ); } )->middleware( 'auth' ); + + Route::register_all( 'rest' ); + + // Test Forbidden + $request = new WP_REST_Request( 'GET', '/wpmvc/secure' ); + $response = rest_do_request( $request ); + $this->assertEquals( 403, $response->get_status() ); + + // Test Allowed + $request->set_param( 'token', 'secret' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'secret-data', $response->get_data() ); + } + + /** + * Test Multi-level Nested Groups. + */ + public function test_multi_level_groups() { + Route::group( + 'api', function() { + Route::group( + 'v1', function() { + Route::get( 'users', function() { return Response::send( 'users' ); } )->name( 'api.v1.users' ); + } + ); + } + ); + + Route::register_all( 'rest' ); + + $url = Router::url( 'api.v1.users' ); + $this->assertTrue( + strpos( $url, 'wpmvc/api/v1/users' ) !== false, + "URL does not contain expected_path: $url" + ); + + $request = new WP_REST_Request( 'GET', '/wpmvc/api/v1/users' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'users', $response->get_data() ); + } + + /** + * Test optional parameters. + */ + public function test_optional_parameters() { + Route::get( + 'profile/{tab?}', function( $tab = 'overview' ) { + return Response::send( $tab ); + } + ); + Route::register_all( 'rest' ); + + // Test without optional param + $request = new WP_REST_Request( 'GET', '/wpmvc/profile' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'overview', $response->get_data() ); + + // Test with optional param + $request = new WP_REST_Request( 'GET', '/wpmvc/profile/settings' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + $this->assertEquals( 'settings', $response->get_data() ); + } + + /** + * Test Resource filtering with 'only' and 'except'. + */ + public function test_resource_filtering() { + // Only index + Route::resource( 'tags', TestController::class )->only( ['index'] ); + + // Except delete + Route::resource( 'comments', TestController::class )->except( ['delete'] ); + + Route::register_all( 'rest' ); + + // Tags should have index but not store + $this->assertEquals( 200, rest_do_request( new WP_REST_Request( 'GET', '/wpmvc/tags' ) )->get_status() ); + $this->assertEquals( 404, rest_do_request( new WP_REST_Request( 'POST', '/wpmvc/tags' ) )->get_status() ); + + // Comments should have index and show, but NOT delete + $this->assertEquals( 200, rest_do_request( new WP_REST_Request( 'GET', '/wpmvc/comments' ) )->get_status() ); + $this->assertEquals( 200, rest_do_request( new WP_REST_Request( 'GET', '/wpmvc/comments/1' ) )->get_status() ); + $this->assertEquals( 404, rest_do_request( new WP_REST_Request( 'DELETE', '/wpmvc/comments/1' ) )->get_status() ); + } + + /** + * Test nested middleware inheritance. + */ + public function test_nested_middleware_inheritance() { + \WpMVC\Routing\Middleware::set_middleware_list( + [ + 'auth' => AuthMiddleware::class, + 'admin' => class_exists( 'AdminMiddleware' ) ? 'AdminMiddleware' : AuthMiddleware::class // Reuse for simplicity + ] + ); + + Route::group( + 'admin', function() { + Route::get( 'dashboard', function() { return 'ok'; } )->middleware( 'auth' ); + } + )->middleware( ['admin'] ); + + Route::register_all( 'rest' ); + + $request = new WP_REST_Request( 'GET', '/wpmvc/admin/dashboard' ); + + // Should require both 'admin' and 'auth'. + // In our case both check for 'token=secret'. + $response = rest_do_request( $request ); + $this->assertEquals( 403, $response->get_status() ); + + $request->set_param( 'token', 'secret' ); + $response = rest_do_request( $request ); + $this->assertEquals( 200, $response->get_status() ); + } + + /** + * Test Ajax error handling (401 and 404). + */ + public function test_ajax_error_handling() { + global $wp; + + // 401 Unauthorized + \WpMVC\Routing\Middleware::set_middleware_list( ['auth' => AuthMiddleware::class] ); + Route::get( 'ajax-secure', function() { return 'ok'; } )->middleware( 'auth' ); + + $wp = new class { public $request = 'wpmvc/ajax-secure'; }; + ob_start(); + Ajax::register_all( 'ajax' ); + $output = ob_get_clean(); + $this->assertStringContainsString( 'ajax_forbidden', $output ); + + // 404 Not Found + Ajax::clear(); // Reset state for the next check + + $wp = new class { public $request = 'wpmvc/non-existent'; }; + ob_start(); + // RouteServiceProvider normally handles the 404 logic if Ajax::$route_found is false + // But for direct Ajax::register_all calling, we just check route_found + Ajax::register_all( 'ajax' ); + ob_get_clean(); + $this->assertFalse( Ajax::$route_found ); + } + + /** + * Test Ajax route matching. + */ + public function test_ajax_route_matching() { + global $wp; + + $wp = new class { public $request = 'wpmvc/ajax-test'; }; + + Route::get( 'ajax-test', function() { return Response::send( ['ajax' => true] ); } )->name( 'ajax.test' ); + + ob_start(); + // We use Ajax::register_all() for "ajax" type + Ajax::register_all( 'ajax' ); + $output = ob_get_clean(); + + $this->assertTrue( Ajax::$route_found ); + $this->assertStringContainsString( '{"ajax":true}', $output ); + } +} diff --git a/tests/Integration/SampleIntegrationTest.php b/tests/Integration/SampleIntegrationTest.php deleted file mode 100644 index 83bc76e..0000000 --- a/tests/Integration/SampleIntegrationTest.php +++ /dev/null @@ -1,12 +0,0 @@ -assertTrue( function_exists( 'get_bloginfo' ) ); - } -} diff --git a/tests/Integration/TestController.php b/tests/Integration/TestController.php new file mode 100644 index 0000000..f551f1c --- /dev/null +++ b/tests/Integration/TestController.php @@ -0,0 +1,44 @@ + $id, + 'has_request' => $request instanceof WP_REST_Request + ] + ); + } + + public function store() { + return Response::send( 'store', 201 ); + } + + public function update( $id ) { + return Response::send( "update-{$id}" ); + } + + public function delete( $id ) { + return Response::send( "delete-{$id}" ); + } + + public function multipart( $first, $second, $third ) { + return Response::send( + [ + '1' => $first, + '2' => $second, + '3' => $third + ] + ); + } +} diff --git a/tests/Unit/PendingRouteTest.php b/tests/Unit/PendingRouteTest.php new file mode 100644 index 0000000..27fe841 --- /dev/null +++ b/tests/Unit/PendingRouteTest.php @@ -0,0 +1,37 @@ +name( 'user.index' )->middleware( ['auth'] ); + + $this->assertSame( $route, $returned ); + + $details = $route->get_details(); + $this->assertEquals( 'user.index', $details['name'] ); + $this->assertEquals( ['auth'], $details['middleware'] ); + } + + public function test_group_middleware_merging() { + $route = new PendingRoute( 'GET', 'profile', 'callback', ['group-mid'] ); + $route->middleware( ['route-mid'] ); + + $details = $route->get_details(); + $this->assertEquals( ['group-mid', 'route-mid'], $details['middleware'] ); + } + + public function test_prefix_appending() { + $route = new PendingRoute( 'GET', 'settings', 'callback' ); + $route->prefix( 'user' ); + + $details = $route->get_details(); + $this->assertEquals( 'user', $details['prefix'] ); + } +} diff --git a/tests/Unit/RouterTest.php b/tests/Unit/RouterTest.php new file mode 100644 index 0000000..5fb0042 --- /dev/null +++ b/tests/Unit/RouterTest.php @@ -0,0 +1,41 @@ +assertCount( 1, Router::get_routes() ); + $this->assertSame( $route, Router::get_routes()[0] ); + } + + public function test_named_route_registry() { + $route = new PendingRoute( 'GET', 'test', 'cb' ); + Router::add_named_route( 'my.route', $route ); + + $this->assertSame( $route, Router::get_named_route( 'my.route' ) ); + $this->assertNull( Router::get_named_route( 'non.existent' ) ); + } + + public function test_clear_pending() { + $route = new PendingRoute( 'GET', 'test', 'cb' ); + Router::add_route( $route ); + Router::add_named_route( 'name', $route ); + + Router::clear_pending(); + + $this->assertEmpty( Router::get_routes() ); + $this->assertNotNull( Router::get_named_route( 'name' ) ); + } +} diff --git a/tests/Unit/SampleTest.php b/tests/Unit/SampleTest.php deleted file mode 100644 index 802b2e6..0000000 --- a/tests/Unit/SampleTest.php +++ /dev/null @@ -1,12 +0,0 @@ -assertTrue( true ); - } -} From 0f6c102eb19ef6e0dc40d556d3178fef5a163f4c Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:28:09 +0600 Subject: [PATCH 2/8] Delete LICENSE --- LICENSE | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 LICENSE diff --git a/LICENSE b/LICENSE deleted file mode 100644 index ba3c1e8..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 WpMVC - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. From 9d281c850cff8aee6730f236894a2e29b125a330 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 09:28:16 +0600 Subject: [PATCH 3/8] Create .gitattributes --- .gitattributes | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2cbb4fa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,10 @@ +* text=auto + +/.github export-ignore +/bin export-ignore +/tests export-ignore +/.gitignore export-ignore +/.gitattributes export-ignore +/phpunit.xml export-ignore +/phpcs.xml export-ignore +/README.md export-ignore \ No newline at end of file From 49de3237543660f0f3457523d621bca199373c49 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Tue, 3 Mar 2026 14:22:23 +0600 Subject: [PATCH 4/8] Use container accessors and update usages --- README.md | 34 +++++------------- src/Ajax.php | 2 +- src/Middleware.php | 2 +- src/Pipeline.php | 2 +- src/Providers/RouteServiceProvider.php | 41 +++++++++++++++++++++- src/Route.php | 12 +++---- tests/Integration/RouteIntegrationTest.php | 2 +- 7 files changed, 58 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index a938ddc..6f595a0 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ Here is the structure of the methods that your DI container should have in order namespace MyPlugin\Providers; - use MyPlugin\Container; + use WpMVC\Container\Container; use WpMVC\Routing\Providers\RouteServiceProvider as WpMVCRouteServiceProvider; class RouteServiceProvider extends WpMVCRouteServiceProvider { @@ -403,12 +403,12 @@ Here is the structure of the methods that your DI container should have in order /** * Set Di Container */ - parent::$container = new Container; + static::set_container( new Container ); /** * Set required properties */ - parent::$properties = [ + static::set_properties( [ 'rest' => [ 'namespace' => 'myplugin', 'versions' => ['v1', 'v2'] @@ -417,35 +417,17 @@ Here is the structure of the methods that your DI container should have in order 'namespace' => 'myplugin', 'versions' => [] ], + // Global hooks (optional) + 'rest_response_action_hook' => 'myplugin_rest_response_action', + 'rest_response_filter_hook' => 'myplugin_rest_response_filter', + 'rest_permission_filter_hook' => 'myplugin_rest_permission_filter', 'middleware' => [], 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' - ]; + ] ); parent::boot(); } } - - ``` - -7. (Optional) You can define global hooks to intercept REST API responses and permissions across all routes. Add these to your `$properties` array: - - ```php - parent::$properties = [ - 'rest' => [ - 'namespace' => 'myplugin', - 'versions' => ['v1'] - ], - 'ajax' => [ - 'namespace' => 'myplugin', - 'versions' => [] - ], - // Global hooks (optional) - 'rest_response_action_hook' => 'myplugin_rest_response_action', - 'rest_response_filter_hook' => 'myplugin_rest_response_filter', - 'rest_permission_filter_hook' => 'myplugin_rest_permission_filter', - 'middleware' => [], - 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' - ]; ``` * `rest_response_action_hook`: Fires an action before the response is returned. Passes `WP_REST_Request $request` and `string $final_route`. diff --git a/src/Ajax.php b/src/Ajax.php index cb0ef27..86afd67 100644 --- a/src/Ajax.php +++ b/src/Ajax.php @@ -176,7 +176,7 @@ protected static function get_wp_rest_request( string $method, array $url_params $wp_rest_request->set_headers( $wp_rest_server->get_headers( wp_unslash( $_SERVER ) ) ); $wp_rest_request->set_body( $wp_rest_server->get_raw_data() ); - RouteServiceProvider::$container->set( \WP_REST_Request::class, $wp_rest_request ); + RouteServiceProvider::get_container()->set( \WP_REST_Request::class, $wp_rest_request ); return $wp_rest_request; } diff --git a/src/Middleware.php b/src/Middleware.php index d24d588..2122d4c 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -50,7 +50,7 @@ public static function is_user_allowed( array $middleware, $wp_rest_request = nu } if ( null === $wp_rest_request ) { - $wp_rest_request = RouteServiceProvider::$container->get( \WP_REST_Request::class ); + $wp_rest_request = RouteServiceProvider::get_container()->get( \WP_REST_Request::class ); } $pipes = []; diff --git a/src/Pipeline.php b/src/Pipeline.php index a7c2b86..87790c5 100644 --- a/src/Pipeline.php +++ b/src/Pipeline.php @@ -88,7 +88,7 @@ protected function carry(): Closure { return $pipe( $passable, $stack ); } - $container = Providers\RouteServiceProvider::$container; + $container = Providers\RouteServiceProvider::get_container(); $middleware = $container->get( $pipe ); return $container->call( [$middleware, 'handle'], [$passable, $stack] ); diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 4e9ba9d..e52457f 100644 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -27,10 +27,49 @@ */ abstract class RouteServiceProvider { - public static $container; + /** + * The container instance. + * + * @var mixed + */ + protected static $container; + /** + * The configuration properties. + * + * @var array + */ protected static $properties; + /** + * Set the container instance. + * + * @param mixed $container + * @return void + */ + public static function set_container( $container ): void { + static::$container = $container; + } + + /** + * Get the container instance. + * + * @return mixed + */ + public static function get_container() { + return static::$container; + } + + /** + * Set the configuration properties. + * + * @param array $properties + * @return void + */ + public static function set_properties( array $properties ): void { + static::$properties = $properties; + } + /** * Bootstrap the routing service. * diff --git a/src/Route.php b/src/Route.php index 8bd0599..9b107fc 100644 --- a/src/Route.php +++ b/src/Route.php @@ -244,7 +244,7 @@ public static function register_all( string $type = 'rest' ) { protected static function register_with_wordpress_rest( PendingRoute $route ) { $details = $route->get_details(); - $data_binder = RouteServiceProvider::$container->get( DataBinder::class ); + $data_binder = RouteServiceProvider::get_container()->get( DataBinder::class ); $namespace = $data_binder->get_namespace(); $callback = $details['callback']; @@ -257,7 +257,7 @@ protected static function register_with_wordpress_rest( PendingRoute $route ) { [ 'methods' => $method, 'callback' => function( WP_REST_Request $wp_rest_request ) use( $callback, $final_route ) { - RouteServiceProvider::$container->set( WP_REST_Request::class, $wp_rest_request ); + RouteServiceProvider::get_container()->set( WP_REST_Request::class, $wp_rest_request ); $properties = RouteServiceProvider::get_properties(); @@ -302,10 +302,10 @@ protected static function register_with_wordpress_rest( PendingRoute $route ) { */ protected static function callback( $callback ) { try { - $request = RouteServiceProvider::$container->get( \WP_REST_Request::class ); + $request = RouteServiceProvider::get_container()->get( \WP_REST_Request::class ); $params = $request ? $request->get_url_params() : []; - $response = RouteServiceProvider::$container->call( $callback, $params ); + $response = RouteServiceProvider::get_container()->call( $callback, $params ); // If it's the structure from Response::send() if ( is_array( $response ) && isset( $response['status_code'] ) && array_key_exists( 'data', $response ) ) { @@ -313,7 +313,7 @@ protected static function callback( $callback ) { $data = $response['data']; // If in REST context, wrap in WP_REST_Response to ensure status is respected - if ( class_exists( '\WP_REST_Response' ) && ( defined( 'REST_REQUEST' ) || RouteServiceProvider::$container->get( \WP_REST_Request::class ) ) ) { + if ( class_exists( '\WP_REST_Response' ) && ( defined( 'REST_REQUEST' ) || RouteServiceProvider::get_container()->get( \WP_REST_Request::class ) ) ) { return new \WP_REST_Response( $data, $status_code ); } @@ -403,7 +403,7 @@ public static function get_final_route_path( string $route, string $prefix = '', $route = static::format_route_regex( $route, $wheres ?? [] ); } - $data_binder = RouteServiceProvider::$container->get( DataBinder::class ); + $data_binder = RouteServiceProvider::get_container()->get( DataBinder::class ); $namespace = $data_binder->get_namespace(); $version = $data_binder->get_version(); diff --git a/tests/Integration/RouteIntegrationTest.php b/tests/Integration/RouteIntegrationTest.php index a06d39c..74dd227 100644 --- a/tests/Integration/RouteIntegrationTest.php +++ b/tests/Integration/RouteIntegrationTest.php @@ -106,7 +106,7 @@ public function call( $callback, array $args = [] ) { $data_binder->set_namespace( 'wpmvc' ); $this->container->set( DataBinder::class, $data_binder ); - RouteServiceProvider::$container = $this->container; + RouteServiceProvider::set_container( $this->container ); } /** From 2d519e634044bf8a2db9a521e984e606999f50e4 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Thu, 5 Mar 2026 18:59:29 +0600 Subject: [PATCH 5/8] Update Route.php --- src/Route.php | 44 ++++++++++++++++++-------------------------- 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/Route.php b/src/Route.php index 9b107fc..380f660 100644 --- a/src/Route.php +++ b/src/Route.php @@ -9,6 +9,8 @@ namespace WpMVC\Routing; +use Exception; + defined( 'ABSPATH' ) || exit; use WpMVC\Routing\Providers\RouteServiceProvider; @@ -302,37 +304,24 @@ protected static function register_with_wordpress_rest( PendingRoute $route ) { */ protected static function callback( $callback ) { try { - $request = RouteServiceProvider::get_container()->get( \WP_REST_Request::class ); - $params = $request ? $request->get_url_params() : []; - - $response = RouteServiceProvider::get_container()->call( $callback, $params ); - - // If it's the structure from Response::send() - if ( is_array( $response ) && isset( $response['status_code'] ) && array_key_exists( 'data', $response ) ) { - $status_code = intval( $response['status_code'] ); - $data = $response['data']; - - // If in REST context, wrap in WP_REST_Response to ensure status is respected - if ( class_exists( '\WP_REST_Response' ) && ( defined( 'REST_REQUEST' ) || RouteServiceProvider::get_container()->get( \WP_REST_Request::class ) ) ) { - return new \WP_REST_Response( $data, $status_code ); - } + $response = RouteServiceProvider::get_container()->call($callback); - static::set_status_code( $status_code ); - $data = $response['data']; - } else { - // Otherwise, treat the entire response as data with 200 status - $status_code = 200; - static::set_status_code( $status_code ); - $data = $response; + if (!is_array($response) || !isset($response['status_code'])) { + return $response; } - return $data; + $status_code = intval($response['status_code']); + $data = $response['data']; - } catch ( \Exception $ex ) { - $status_code = intval( $ex->getCode() ); - if ( $status_code < 100 || $status_code > 599 ) { - $status_code = 500; + if (class_exists('WP_REST_Response')) { + return new \WP_REST_Response($data, $status_code); } + + static::set_status_code($status_code); + + return $data; + } catch (Exception $ex) { + $status_code = intval( $ex->getCode() ); static::set_status_code( $status_code ); $response = [ @@ -357,6 +346,9 @@ protected static function callback( $callback ) { $response['message'] = 'Something went wrong.'; } } + if (class_exists('WP_REST_Response')) { + return new \WP_REST_Response($response, $status_code); + } return $response; } From fe989196a426a5cd3111df5a21e21c19b2cabb14 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Fri, 6 Mar 2026 23:05:43 +0600 Subject: [PATCH 6/8] update --- src/Middleware.php | 14 +++------- src/Route.php | 31 +++++++--------------- tests/Integration/RouteIntegrationTest.php | 7 ++++- 3 files changed, 19 insertions(+), 33 deletions(-) diff --git a/src/Middleware.php b/src/Middleware.php index 2122d4c..b3c8f60 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -11,7 +11,7 @@ defined( 'ABSPATH' ) || exit; -use WpMVC\Routing\Providers\RouteServiceProvider; +use WP_REST_Request; use WP_Error; /** @@ -40,19 +40,11 @@ public static function set_middleware_list( array $middleware ): void { * Executes the middleware chain through the Pipeline. * * @param array $middleware The names of middleware to execute. - * @param \WP_REST_Request|null $wp_rest_request The current request instance. + * @param WP_REST_Request $wp_rest_request The current request instance. * @param bool $default_permission The default permission if no middleware fails. * @return bool|WP_Error Returns true if allowed, false if forbidden, or a WP_Error on failure. */ - public static function is_user_allowed( array $middleware, $wp_rest_request = null, bool $default_permission = true ) { - if ( null !== $wp_rest_request && ! $wp_rest_request instanceof \WP_REST_Request ) { - return false; - } - - if ( null === $wp_rest_request ) { - $wp_rest_request = RouteServiceProvider::get_container()->get( \WP_REST_Request::class ); - } - + public static function is_user_allowed( array $middleware, WP_REST_Request $wp_rest_request, bool $default_permission = true ) { $pipes = []; foreach ( $middleware as $name ) { diff --git a/src/Route.php b/src/Route.php index 380f660..c89c4b9 100644 --- a/src/Route.php +++ b/src/Route.php @@ -9,13 +9,13 @@ namespace WpMVC\Routing; -use Exception; - defined( 'ABSPATH' ) || exit; use WpMVC\Routing\Providers\RouteServiceProvider; use WP_Error; +use Exception; use WP_HTTP_Response; +use WP_REST_Response; use WP_REST_Request; /** @@ -273,16 +273,15 @@ protected static function register_with_wordpress_rest( PendingRoute $route ) { return static::callback( $callback ); }, - 'permission_callback' => function( \WP_REST_Request $wp_rest_request ) use( $middleware, $final_route ) { + 'permission_callback' => function( WP_REST_Request $wp_rest_request ) use( $middleware, $final_route ) { $permission = Middleware::is_user_allowed( $middleware, $wp_rest_request ); - $properties = RouteServiceProvider::get_properties(); if ( ! empty( $properties['rest_permission_filter_hook'] ) ) { $permission = apply_filters( $properties['rest_permission_filter_hook'], $permission, $middleware, $final_route ); } - if ( $permission instanceof \WP_Error ) { + if ( $permission instanceof WP_Error ) { $data = $permission->get_error_data(); $status = isset( $data['status'] ) ? $data['status'] : 500; static::set_status_code( (int) $status ); @@ -304,25 +303,18 @@ protected static function register_with_wordpress_rest( PendingRoute $route ) { */ protected static function callback( $callback ) { try { - $response = RouteServiceProvider::get_container()->call($callback); + $response = RouteServiceProvider::get_container()->call( $callback ); - if (!is_array($response) || !isset($response['status_code'])) { + if ( ! is_array( $response ) || ! isset( $response['status_code'] ) ) { return $response; } - $status_code = intval($response['status_code']); + $status_code = intval( $response['status_code'] ); $data = $response['data']; - if (class_exists('WP_REST_Response')) { - return new \WP_REST_Response($data, $status_code); - } - - static::set_status_code($status_code); - - return $data; - } catch (Exception $ex) { + return new WP_REST_Response( $data, $status_code ); + } catch ( Exception $ex ) { $status_code = intval( $ex->getCode() ); - static::set_status_code( $status_code ); $response = [ 'data' => [ @@ -346,11 +338,8 @@ protected static function callback( $callback ) { $response['message'] = 'Something went wrong.'; } } - if (class_exists('WP_REST_Response')) { - return new \WP_REST_Response($response, $status_code); - } - return $response; + return new WP_REST_Response( $response, $status_code ); } } diff --git a/tests/Integration/RouteIntegrationTest.php b/tests/Integration/RouteIntegrationTest.php index 74dd227..3fba744 100644 --- a/tests/Integration/RouteIntegrationTest.php +++ b/tests/Integration/RouteIntegrationTest.php @@ -38,6 +38,12 @@ public function set( $id, $instance ) { $this->instances[$id] = $instance; } public function call( $callback, array $args = [] ) { + if ( empty( $args ) ) { + $request = $this->instances['WP_REST_Request'] ?? null; + if ( $request instanceof \WP_REST_Request ) { + $args = $request->get_params(); + } + } if ( is_array( $callback ) ) { $class = is_object( $callback[0] ) ? $callback[0] : $this->get( $callback[0] ); $method = $callback[1]; @@ -204,7 +210,6 @@ public function test_dynamic_parameter_injection_order() { $data = $response->get_data(); // We need to verify that Route::callback is actually passing parameters to the container. - // Currently it's NOT. This test is expected to FAIL until we update Route.php. $this->assertEquals( '10', $data['1'], "First parameter (post_id) should be '10'" ); $this->assertEquals( '20', $data['2'], "Second parameter (comment_id) should be '20'" ); } From 94ecf0571a365b49605c9c7e00aba586a9c9a8b1 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:17:25 +0600 Subject: [PATCH 7/8] Update README.md --- README.md | 478 +----------------------------------------------------- 1 file changed, 6 insertions(+), 472 deletions(-) diff --git a/README.md b/README.md index 6f595a0..af21d5f 100644 --- a/README.md +++ b/README.md @@ -1,477 +1,11 @@

-Total Downloads -Latest Stable Version -License + Total Downloads + Latest Stable Version + License

-# About WpMVC Routing +# WpMVC Routing -WpMVC Routing is a powerful routing system for WordPress plugins that is similar to the popular PHP framework Laravel. This package makes use of the WordPress REST route system and includes its own custom route system, known as the `Ajax Route`. +A powerful and flexible routing system for WordPress plugins, featuring a fluent interface for REST API and custom AJAX routing. It brings modern routing capabilities like middleware, groups, and resource controllers to the WordPress ecosystem. -One of the key features of WpMVC Routing is its support for middleware. Middleware allows you to perform additional actions before each request. - -By using WpMVC Routing in your WordPress plugin, you can easily create custom routes and middleware to handle a wide variety of requests, including AJAX requests, with ease. This makes it an excellent tool for developing modern and dynamic WordPress plugins that require advanced routing capabilities and additional security measures. - -- [About WpMVC Routing](#about-wpmvc-routing) - - [Installation](#installation) - - [Register Routes In Route File](#register-routes-in-route-file) - - [Rest Route](#rest-route) - - [Write your first route](#write-your-first-route) - - [With Controller](#with-controller) - - [Other HTTP Methods](#other-http-methods) - - [Dynamic Routing](#dynamic-routing) - - [Regular Expression Constraints](#regular-expression-constraints) - - [Named Routes](#named-routes) - - [Route Grouping](#route-grouping) - - [Resource Controller](#resource-controller) - - [Actions Handled By Resource Controller](#actions-handled-by-resource-controller) - - [Ajax Route](#ajax-route) - - [Middleware](#middleware) - - [Requirement & Configuration (Standalone Usage)](#requirement--configuration-standalone-usage) - - [License](#license) - - -## Register Routes In Route File - -### Rest Route -`routes/rest/api.php` - -#### Write your first route -To create your first RESTful route in WordPress, you can use the `Route` and `Response` classes from the `WpMVC\Routing` namespace, as shown below: -```php - 1, 'name' => 'john']); -}); -``` - -In this example, we're using the `get()` method of the Route class to define a `GET request` to the /user endpoint. The closure passed as the second argument returns a response using the `Response::send()` method, which takes an array of data to be returned in `JSON format`. - -You may also pass a custom HTTP status code and an array of custom headers to `Response::send()`: - -```php -return Response::send(['error' => 'Not Found'], 404, ['X-Custom-Header' => 'Value']); -``` -#### With Controller -If you prefer to use a controller for your route logic, you can specify the controller and method as an array, as shown below: - -```php -Route::get('user', [UserController::class, 'index']); -``` -Here, we're using the `get()` method of the Route class to define a `GET request` to the /user endpoint. We're specifying the controller class and method as an array, where `UserController::class` refers to the class name and `index` is the method name. - -#### Other HTTP Methods -Along with `get`, WpMVC Routing provides methods for all common HTTP verbs: `post`, `put`, `patch`, and `delete`. - -```php -Route::post('user', [UserController::class, 'store']); -Route::put('user/{id}', [UserController::class, 'update']); -Route::patch('user/{id}', [UserController::class, 'update']); -Route::delete('user/{id}', [UserController::class, 'destroy']); -``` - -If you need a route to respond to multiple verbs, you can use the `match` method. Or, if you want a route to respond to all HTTP verbs, use the `any` method: - -```php -Route::match(['GET', 'POST'], '/', function () { - // -}); - -Route::any('/', function () { - // -}); -``` - -#### Dynamic Routing -You can use dynamic routing to handle requests to endpoints with dynamic parameters. To define a route with a required parameter, use curly braces around the parameter name, as shown below: - -```php -// Required id -Route::get('users/{id}', [UserController::class, 'index']); -``` - -To define a route with an optional parameter, you can add a question mark after the parameter name, as shown below: - -```php -// Optional id -Route::get('users/{id?}', [UserController::class, 'index']); -``` - -#### Regular Expression Constraints -You may constrain the format of your route parameters using the `where` method on a route instance. The `where` method accepts the name of the parameter and a regular expression defining how the parameter should be constrained: - -```php -Route::get('users/{name}', [UserController::class, 'show'])->where('name', '[A-Za-z]+'); - -Route::get('users/{id}', [UserController::class, 'show'])->where('id', '[0-9]+'); -``` - -You can constrain multiple parameters at once by passing an array to the `wheres` method: - -```php -Route::get('users/{id}/{name}', [UserController::class, 'show'])->wheres([ - 'id' => '[0-9]+', - 'name' => '[a-z]+' -]); -``` - -#### Route Prefixing -You may prefix the URI of an individual route by chaining the `prefix` method onto the route definition: - -```php -Route::get('profile', [UserProfileController::class, 'show'])->prefix('user'); -// Matches: /user/profile -``` - -#### Named Routes -Named routes allow the convenient generation of URLs to specific routes. You may specify a name for a route by chaining the `name` method onto the route definition: - -```php -// routes/rest/api.php or routes/ajax/api.php -Route::get('user/profile', [UserProfileController::class, 'show'])->name('profile'); - -// With parameters... -Route::get('user/{id}/profile', [UserProfileController::class, 'edit'])->name('profile.edit'); -``` - -Once you have assigned a name to a given route, you may use the route's name when generating URLs via the `Router::url` method: - -```php -// Inside a Controller method or route callback -use WpMVC\Routing\Router; - -// Generating URLs... -$url = Router::url('profile'); - -$url = Router::url('profile.edit', ['id' => 1]); -``` - -> [!IMPORTANT] -> `Router::url()` will only work inside route callbacks (such as inside a Controller method or a route closure). If you need to generate an API URL outside of a route callback, you should use the native WordPress `get_rest_url()` function as demonstrated below. - -#### Get Api Endpoint - -The `get_rest_url()` function can be used to get the REST API endpoint for the current site. To use this function, you need to provide the current site ID and the `namespace` for your plugin. - -```php -$site_id = get_current_blog_id(); -$namespace = 'myplugin'; - -$rest_route_path = get_rest_url($site_id, $namespace); - -$user_rest_route = $rest_route_path . '/user'; -``` - -Similarly, you can use the `get_site_url()` function to get the URL of the current site, and then append your namespace to it to create the AJAX API endpoint URL. - -```php -$ajax_route_path = get_site_url($site_id) . '/' . $namespace; - -$user_ajax_route = $ajax_route_path . '/user'; -``` - -#### Route Grouping -You can group related routes together using the `group()` method. This allows you to apply attributes such as prefixes and middleware to multiple routes at once without defining them on each route. You can create `nested groups` as well, as shown below: - -```php -Route::group('admin', function() { - - Route::get('/', [UserController::class, 'index']); - - Route::group('user', function() { - Route::get('/', [UserController::class, 'index']); - Route::post('/', [UserController::class, 'store']); - Route::get('/{id}', [UserController::class, 'show']); - Route::patch('/{id}', [UserController::class, 'update']); - Route::delete('/{id}', [UserController::class, 'delete']); - } ); -} )->middleware('admin'); -``` - -#### Resource Controller -Resource routing is a powerful feature that allows you to quickly assign CRUD `(create, read, update, delete)` routes to a controller with a single line of code. To create resource routes, you can use the `resource()` method. Here is an example: - -```php -Route::resource('user', UserController::class); -``` - -Resource routing automatically generates the typical CRUD routes for your controller, as shown in the table below: - -##### Actions Handled By Resource Controller - -| Verb | URI | Action | -|--------|---------------|--------| -| GET | /users | index | -| POST | /users | store | -| GET | /users/{user} | show | -| PATCH | /users/{user} | update | -| DELETE | /users/{user} | delete | - -If you only need to bind a portion of the resource actions to a controller, you can use the `only` and `except` methods: - -```php -Route::resource('user', UserController::class)->only(['index', 'show']); - -Route::resource('user', UserController::class)->except(['store', 'update', 'delete']); -``` - -You can also chain `middleware()` and `name()` directly onto the resource registration to apply them to all generated routes: - -```php -Route::resource('user', UserController::class)->middleware('auth')->name('users'); -``` - -With resource routing, you don't have to define each route separately. Instead, you can handle all of the CRUD operations in a single controller class, making it easier to organize your code and keep your routes consistent. - -#### Route Macros -The `Route` class uses the `Macroable` trait, which allows you to easily add your own custom methods to the router. Once a macro is defined, you can use it just like any other method on the `Route` class. - -```php -use WpMVC\Routing\Route; -use WpMVC\Routing\Response; - -Route::macro('status', function (string $uri) { - return Route::get($uri, function () { - return Response::send(['status' => 'OK']); - }); -}); - -// Usage -Route::status('system/status'); -``` - -### Ajax Route - -`routes/ajax/api.php` - -Sometimes third-party plugins don't load when using the WordPress rest route. To fix this issue we are creating our own route system (Ajax Route). - -Registering an AJAX route is similar to registering a REST route. Instead of using the `Route` class, you need to use the `Ajax` class. - -Here is an example of registering an AJAX route to get a user's data: -```php -use WpMVC\Routing\Ajax; -use WP_REST_Request; - -Ajax::get('user', function(WP_REST_Request $wp_rest_request) { - return Response::send(['ID' => 1, 'name' => 'john']); -}); -``` - -To route to `WordPress admin`, your route must use a middleware with the name `admin`. If you apply this middleware to your Ajax route, WpMVC will load the WP admin code. Check out the [Middleware Docs](#middleware) to see the middleware use process. - -## Middleware - -To create a middleware, you need to implement the `Middleware` interface. The `handle` method of the middleware will receive the current `WP_REST_Request` and a `$next` closure representing the pipeline payload. - -If the handle method returns `false`, or a `WP_Error`, the pipeline stops immediately. Returning `true` or calling `$next($request)` allows the request to continue. - -Here is an example of creating a middleware class named `EnsureIsUserAdmin`: - -```php - [ - 'admin' => \MyPlugin\App\Http\Middleware\EnsureIsUserAdmin::class - ] -]; -``` - -To use the middleware in a route, you can chain the `middleware` method onto the route definition. You may assign multiple middleware by passing an array of names: - -```php -// Assign single middleware -Route::get('/admin', [AdminController::class, 'index'])->middleware('admin'); - -// Assign multiple middleware -Route::get('/admin', [AdminController::class, 'index'])->middleware(['admin', 'auth']); -``` - -## Requirement & Configuration (Standalone Usage) - -> [!NOTE] -> If you are using WpMVC, these configurations and requirements are already handled for you by the framework. The following instructions are only necessary if you are integrating the `wpmvc/routing` package independently into another WordPress plugin ecosystem. - -### Requirement - -WpMVC routing requires a dependency injection (DI) container. We do not use any hard-coded library, so you can choose to use any DI library you prefer. However, it is important to follow our DI structure, which includes having the `set` and `get` methods in your DI container. - -We recommend using [wpmvc/container](https://github.com/wpmvc/container) as it already has these 2 methods implemented in the package and supports PHP 7.4 to 8.5. - -#### Methods structure -Here is the structure of the methods that your DI container should have in order to work with WpMVC routing: - -1. `set` method - - ```php - /** - * Define an object or a value in the container. - * - * @param string $name Entry name - * @param mixed $value Value, define objects - */ - public function set(string $name, $value) {} - ``` -2. `get` method - - ```php - - /** - * Returns an entry of the container by its name. - * - * @template T - * @param string|class-string $name Entry name or a class name. - * - * @return mixed|T - */ - public function get($name) {} - ``` - -### Configuration -1. Your plugin must include a `routes` folder. This folder will contain all of your plugin's route files. - -2. Within the `routes` folder, create two subfolders: `ajax` and `rest`. These folders will contain your plugin's route files for AJAX and REST requests, respectively. - -3. If you need to support different versions of your routes, you can create additional files within the `ajax` and `rest` subfolders. For example, you might create `v1.php` and `v2.php` files within the `ajax` folder to support different versions of your AJAX routes. - -4. Folder structure example: - ``` - routes: - rest: - api.php - v1.php - v2.php - ajax: - api.php - v1.php - ``` -5. In your `RouteServiceProvider` class, set the necessary properties for your route system. This includes setting the `rest and ajax namespaces`, the versions of your routes, any middleware you want to use, and the directory where your route files are located. Here's an example: - ```php - - [ - 'namespace' => 'myplugin', - 'versions' => ['v1', 'v2'] - ], - 'ajax' => [ - 'namespace' => 'myplugin', - 'versions' => [] - ], - // Global hooks (optional) - 'rest_response_action_hook' => 'myplugin_rest_response_action', - 'rest_response_filter_hook' => 'myplugin_rest_response_filter', - 'rest_permission_filter_hook' => 'myplugin_rest_permission_filter', - 'middleware' => [], - 'routes-dir' => ABSPATH . 'wp-content/plugins/my-plugin/routes' - ] ); - - parent::boot(); - } - } - ``` - - * `rest_response_action_hook`: Fires an action before the response is returned. Passes `WP_REST_Request $request` and `string $final_route`. - * `rest_response_filter_hook`: Filters the final response data. Passes `mixed $response`, `WP_REST_Request $request`, and `string $final_route`. - * `rest_permission_filter_hook`: Filters the boolean/WP_Error result of the middleware pipeline. Passes `bool|WP_Error $permission`, `array $middleware`, and `string $final_route`. - - **Example hook usage:** - - ```php - // Action Hook Example - add_action('myplugin_rest_response_action', function (\WP_REST_Request $request, string $final_route) { - // Log the request - error_log("Route {$final_route} accessed."); - }, 10, 2); - - // Response Filter Example - add_filter('myplugin_rest_response_filter', function ($response, \WP_REST_Request $request, string $final_route) { - // Modify the response data - if (is_array($response)) { - $response['timestamp'] = time(); - } - return $response; - }, 10, 3); - - // Permission Filter Example - add_filter('myplugin_rest_permission_filter', function ($permission, array $middleware, string $final_route) { - // E.g. Deny access if a specific option is enabled - if (get_option('myplugin_maintenance_mode')) { - return new \WP_Error('maintenance', 'Site is under maintenance', ['status' => 503]); - } - return $permission; - }, 10, 3); - ``` - -8. Finally, execute the `boot` method of your `RouteServiceProvider` class using the `init` action hook, like so: - - ```php - add_action('init', function() { - $route_service_provider = new \MyPlugin\Providers\RouteServiceProvider; - $route_service_provider->boot(); - }); - ``` -That's it! Your plugin is now configured with WpMVC Routing system, and you can start creating your own routes and handling requests with ease. - -## License - -WpMVC Routing is open-sourced software licensed under the [MIT license](https://opensource.org/licenses/MIT). +For a full list of available features and detailed usage guides, please refer to the [Routing Documentation](https://wpmvc.com/docs/routing/getting-started). From 3081c0f762f62c68c46d7ccfe3a5ac741346a626 Mon Sep 17 00:00:00 2001 From: MD AL AMIN <75071900+mdalaminbey@users.noreply.github.com> Date: Sun, 8 Mar 2026 21:18:13 +0600 Subject: [PATCH 8/8] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index af21d5f..83f3484 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,4 @@ A powerful and flexible routing system for WordPress plugins, featuring a fluent interface for REST API and custom AJAX routing. It brings modern routing capabilities like middleware, groups, and resource controllers to the WordPress ecosystem. -For a full list of available features and detailed usage guides, please refer to the [Routing Documentation](https://wpmvc.com/docs/routing/getting-started). +For a full list of available features and detailed usage guides, please refer to the [Routing Documentation](https://wpmvc.com/docs/routing).