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
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.
diff --git a/README.md b/README.md
index d2c5468..83f3484 100644
--- a/README.md
+++ b/README.md
@@ -1,313 +1,11 @@
-
-
-
+
+
+
-# 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)
- - [Requirement](#requirement)
- - [Methods structure](#methods-structure)
- - [Installation](#installation)
- - [Configuration](#configuration)
- - [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)
- - [Dynamic Routing](#dynamic-routing)
- - [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)
- - [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
-`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`.
-#### 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.
-
-#### 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']);
-```
-#### 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:
-
-```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']);
- } );
-} );
-```
-
-#### 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 |
-
-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.
-
-### 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.
-
-### 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.
-
-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 add the middleware name as the last argument of the route definition, as shown below:
-
-```php
-Route::get('/admin', [AdminController::class, 'index'], ['admin']);
-```
-
-## 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).
diff --git a/src/Ajax.php b/src/Ajax.php
index ada0271..86afd67 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::get_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..b3c8f60 100644
--- a/src/Middleware.php
+++ b/src/Middleware.php
@@ -1,47 +1,70 @@
get( $current_middleware );
-
- if ( ! $middleware_object instanceof MiddlewareContract ) {
- return false;
- }
+ if ( empty( $pipes ) ) {
+ return $default_permission;
+ }
- $permission = $container->call( [$middleware_object, 'handle'] );
+ $pipeline = new Pipeline();
- if ( $permission instanceof WP_Error || ! $permission ) {
- return $permission;
- }
- }
-
- return $default_permission;
+ 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..87790c5
--- /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::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 1163032..e52457f 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 +109,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 +154,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 +192,99 @@ 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 = [] ) {
- $data_binder = RouteServiceProvider::$container->get( DataBinder::class );
+ /**
+ * 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::get_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 ) {
- RouteServiceProvider::$container->set( WP_REST_Request::class, $wp_rest_request );
+ 'callback' => function( WP_REST_Request $wp_rest_request ) use( $callback, $final_route ) {
+ RouteServiceProvider::get_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() );
+ $data = $permission->get_error_data();
+ $status = isset( $data['status'] ) ? $data['status'] : 500;
+ static::set_status_code( (int) $status );
}
return $permission;
}
@@ -138,28 +293,28 @@ 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 );
+ $response = RouteServiceProvider::get_container()->call( $callback );
- if ( ! is_array( $response ) ) {
- exit;
+ if ( ! is_array( $response ) || ! isset( $response['status_code'] ) ) {
+ return $response;
}
$status_code = intval( $response['status_code'] );
- static::set_status_code( $status_code );
-
- $response = $response['data'];
+ $data = $response['data'];
- if ( $status_code > 399 && 600 > $status_code ) {
- $response['data']['status'] = $status_code;
- return $response;
- }
-
- return $response;
+ return new WP_REST_Response( $data, $status_code );
} catch ( Exception $ex ) {
$status_code = intval( $ex->getCode() );
- static::set_status_code( $status_code );
$response = [
'data' => [
@@ -183,10 +338,17 @@ protected static function callback( $callback ) {
$response['message'] = 'Something went wrong.';
}
}
- return $response;
+
+ return new WP_REST_Response( $response, $status_code );
}
}
+ /**
+ * 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,15 +364,27 @@ 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 );
+ $data_binder = RouteServiceProvider::get_container()->get( DataBinder::class );
$namespace = $data_binder->get_namespace();
$version = $data_binder->get_version();
@@ -220,7 +394,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 +409,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..3fba744
--- /dev/null
+++ b/tests/Integration/RouteIntegrationTest.php
@@ -0,0 +1,395 @@
+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 ( 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];
+ $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::set_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.
+ $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 );
- }
-}