diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5572f08
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,57 @@
+# Composer
+/vendor/
+composer.lock
+
+# PHP
+*.log
+*.cache
+.php_cs.cache
+.phpunit.result.cache
+
+# IDE and Editor files
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# WordPress specific
+*.sql
+*.sql.gz
+wp-config.php
+wp-content/advanced-cache.php
+wp-content/backup-db/
+wp-content/backups/
+wp-content/blogs.dir/
+wp-content/cache/
+wp-content/upgrade/
+wp-content/uploads/
+wp-content/wp-cache-config.php
+wp-content/plugins/hello.php
+
+# Environment files
+.env
+.env.*
+!.env.example
+
+# Node modules (if using build tools)
+/node_modules/
+npm-debug.log
+yarn-debug.log
+yarn-error.log
+
+# Build and dist directories
+/dist/
+/build/
+.sass-cache/
+
+# Testing
+/coverage/
+/tests/coverage/
+.phpunit.cache/
+
+# Operating System
+Thumbs.db
+ehthumbs.db
+Desktop.ini
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f1afe9c
--- /dev/null
+++ b/README.md
@@ -0,0 +1,200 @@
+# WP CoderPress Endpoints
+
+This package intends to provide a more straightforward way to add custom endpoints to Wordpress Rest API (added to mu-plugins with no hooks, as a suggestion), also using a Facade pattern which allows:
+* To attach PSR-16 compliant cache drivers of any nature
+* To attach as many middlewares as you want to modify the request or provide a particular response
+* Interfere in the way cached responses are stored and retrieved (JSON, serialize(), igbinary_serialize())
+* Provide a port to add multiple custom endpoints without repeating code and sharing cache, middleware and variables within, including a dynamic endpoint generation
+* Offer common middlewares to handle common scenarios in REST API development
+
+# Basic Example
+
+The following example creates a very simple example endpoint to the rest API that comes with two middlewares attached (as \Closure objects). The first one modifies any passed parameter to "id = 123", which triggers the second middleware and returns a WP_REST_Response - this also prevents the cache to be done.
+
+If the second middleware is removed, the endpoint is no longer short-circuited, so the results are now serialized and cached in a file within wp-content/cache/. In the example, the cache is managed by FileCache, but this package also includes usable drivers for Redis (using redis-php extension) and MySQL cache, using a custom DB table.
+
+```php
+use CoderPress\Rest\{RestEndpointFacade, AbstractRestEndpoint};
+use CoderPress\Cache\{RedisCache, MySqlCache, FileCache};
+
+require __DIR__ . '/vendor/autoload.php';
+
+$cacheInstance = new FileCache(WP_CONTENT_DIR . '/cache/');
+
+$custom = RestEndpointFacade::createEndpoint(
+ /** The five first arguments stand as they are for register_rest_route()
+ * but with callbacks now accepting \Closure functions instead.
+ *
+ * The middlewares arguments accepts an array of closures which will be
+ * sequentially executed and MUST have a $request parameter.
+ *
+ * The cache mechanism is automatically applied to the endpoint and accepts
+ * any PSR-16 compliant object. Optionally, the expire time and the type
+ * of serialization can be changed. Expires accepts any value in seconds as
+ * integer or a Datetime object (and then the time in seconds between that and
+ * the current time will be automatically extracted)
+ *
+ */
+ namespace: 'testing/v1',
+ route: 'custom/',
+ args: [
+ 'id' => [
+ 'validate_callback' => function($param, $request, $key) {
+ return is_numeric($param);
+ }
+ ],
+ ],
+ callback: function (\WP_REST_Request $request) {
+ $postId = $request->get_param('id');
+ $postCategoryIds = wp_get_object_terms($postId, ['category'], ['fields' => 'ids']);
+
+ return get_posts([
+ 'post_status' => 'publish',
+ 'category__in' => $postCategoryIds,
+ 'order' => 'relevance'
+ ]);
+ },
+ permissionCallback: function () {
+ return is_user_logged_in();
+ },
+ /**
+ * Accepts any number of /Closure middleware functions - to each one of them,
+ * the $request object must be passed. For changes made to the WP_REST_Request object,
+ * the closure must return void(). However, the middleware can also return a response
+ * to the request easily by return a new WP_REST_Response object instead.
+ */
+ middlewares: [
+ function (\WP_REST_Request $request) {
+ $request->set_param('id', 123);
+ },
+ function (\WP_REST_Request $request) {
+ if ($request->get_param('id') == 123) {
+ return new \WP_REST_Response(['message' => 'Invalid value'], 201);
+ }
+ }
+ ],
+ /**
+ * Accepts any instance PSR-16 compliant (CacheInterface contract),
+ * this package comes with 3 usable examples (file, Redis and custom MySQL
+ * table caching drivers).
+ */
+ cache: $cacheInstance,
+ /**
+ * Accepts 3 different serialize/unserialize methods - defaults to serialize(),
+ * but can be using JSON - AbstractRestEndpoint::SERIALIZE_JSON - or igbinary PHP
+ * extension which offers a more efficient serializing version
+ * - AbstractRestEndpoint::SERIALIZE_IGBINARY.
+ */
+ cacheSerializingMethod: AbstractRestEndpoint::SERIALIZE_PHP,
+ /**
+ * Accepts seconds as integer value, but also DateTime objects - for the latter,
+ * the system will get the interval between NOW and the passed DateTime object time,
+ * so the cache will work as scheduled.
+ */
+ cacheExpires: (new \DateTime())->modify('+1 day')
+);
+```
+
+# Middlewares
+
+The package includes several built-in middlewares to handle common scenarios in REST API development:
+
+## CORS Middleware
+
+The CORS (Cross-Origin Resource Sharing) middleware allows you to configure cross-origin requests to your API endpoints. Here's how to use it:
+
+```php
+use CoderPress\Rest\RestEndpointFacade;
+use CoderPress\Rest\Middleware\MiddlewareFactory;
+
+// Create an endpoint with CORS support
+RestEndpointFacade::createEndpoint(
+ namespace: 'api/v1',
+ route: 'posts',
+ callback: function($request) {
+ return ['data' => 'Your API response'];
+ },
+ permissionCallback: function() {
+ return true;
+ },
+ args: [],
+ methods: ['GET', 'POST', 'OPTIONS'],
+ middlewares: [
+ MiddlewareFactory::cors(
+ allowedOrigins: ['https://your-domain.com'],
+ allowedMethods: ['GET', 'POST'],
+ allowedHeaders: ['Content-Type', 'X-Custom-Header'],
+ maxAge: 7200
+ )
+ ]
+);
+```
+
+The CORS middleware provides:
+- Origin validation against a whitelist
+- Configurable HTTP methods
+- Customizable allowed headers
+- Preflight request handling
+- Configurable cache duration for preflight responses
+- Credentials support
+
+All parameters are optional and come with sensible defaults for typical API usage.
+
+## Sanitization Middleware
+
+The Sanitization middleware provides comprehensive input sanitization and validation for your API endpoints. It helps prevent XSS attacks, SQL injection, and ensures data consistency:
+
+```php
+use CoderPress\Rest\RestEndpointFacade;
+use CoderPress\Rest\Middleware\MiddlewareFactory;
+
+RestEndpointFacade::createEndpoint(
+ namespace: 'api/v1',
+ route: 'posts',
+ callback: function($request) {
+ return ['data' => $request->get_params()];
+ },
+ permissionCallback: function() {
+ return true;
+ },
+ args: [
+ 'title' => [
+ 'required' => true,
+ 'type' => 'string'
+ ],
+ 'content' => [
+ 'required' => true,
+ 'type' => 'string'
+ ]
+ ],
+ methods: ['POST'],
+ middlewares: [
+ MiddlewareFactory::sanitization(
+ rules: [
+ // Custom sanitization rules for specific fields
+ 'title' => fn($value) => sanitize_title($value),
+ 'content' => fn($value) => wp_kses_post($value)
+ ],
+ stripTags: true,
+ encodeSpecialChars: true,
+ allowedHtmlTags: [
+ 'p' => [],
+ 'a' => ['href' => [], 'title' => []],
+ 'b' => [],
+ 'i' => []
+ ]
+ )
+ ]
+);
+```
+
+The middleware provides:
+- Field-specific sanitization rules using callbacks
+- HTML tag stripping with configurable allowed tags
+- Special character encoding
+- UTF-8 validation
+- Recursive array sanitization
+- WordPress-specific sanitization functions integration
+- XSS and SQL injection protection
+
+All parameters are optional and come with secure defaults. Use the `rules` parameter to define custom sanitization logic for specific fields.
\ No newline at end of file
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..05fc6b4
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,34 @@
+{
+ "name": "cmatosbc/wp-coderpress-endpoints",
+ "description": "Endpoints builder and manager for CoderPress",
+ "license": "gpl-3.0-or-later",
+ "type": "library",
+ "authors": [
+ {
+ "name": "Carlos Matos",
+ "email": "carlosarturmatos1977@gmail.com"
+ }
+ ],
+ "config": {
+ "sort-packages": true
+ },
+ "autoload": {
+ "psr-4": {
+ "CoderPress\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "CoderPress\\Tests\\": "tests/"
+ }
+ },
+ "require": {
+ "php": ">=8.0",
+ "psr/simple-cache": "^3.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^9.0",
+ "brain/monkey": "^2.6",
+ "psr/http-server-handler": "^1.0"
+ }
+}
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..b9e21c1
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,17 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/src/Cache/FileCache.php b/src/Cache/FileCache.php
new file mode 100644
index 0000000..e84f324
--- /dev/null
+++ b/src/Cache/FileCache.php
@@ -0,0 +1,116 @@
+directory = $directory;
+ if (!is_dir($this->directory)) {
+ mkdir($this->directory, 0777, true);
+ }
+ }
+
+ private function getFilePath(string $key): string
+ {
+ return $this->directory . '/' . $key . '.cache';
+ }
+
+ public function get(string $key, mixed $default = null): mixed
+ {
+ $filePath = $this->getFilePath($key);
+ if (!file_exists($filePath)) {
+ return $default;
+ }
+
+ $expirationTime = fileatime($filePath) + (int) ($this->ttl ?? 0);
+ if ($expirationTime < time()) {
+ unlink($filePath);
+ return $default;
+ }
+
+ $data = file_get_contents($filePath);
+ if ($data === false) {
+ return $default;
+ }
+
+ return $data;
+ }
+
+ public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
+ {
+ $filePath = $this->getFilePath($key);
+ $data = $value;
+
+ if ($ttl !== null) {
+ $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl;
+ touch($filePath, mtime: time() + $ttl, atime: time() + $ttl);
+ }
+
+ return file_put_contents($filePath, $data) !== false;
+ }
+
+ public function delete(string $key): bool
+ {
+ $filePath = $this->getFilePath($key);
+ return unlink($filePath);
+ }
+
+ public function clear(): bool
+ {
+ $files = glob($this->directory . '/*.cache');
+ foreach ($files as $file) {
+ unlink($file);
+ }
+ return true;
+ }
+
+ public function getMultiple(iterable $keys, mixed $default = null): iterable
+ {
+ $results = [];
+ foreach ($keys as $key) {
+ $results[$key] = $this->get($key, $default);
+ }
+ return $results;
+ }
+
+ public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
+
+ {
+ $success = true;
+ foreach ($values as $key => $value) {
+ $success = $success && $this->set($key, $value, $ttl);
+ }
+ return $success;
+ }
+
+ public function deleteMultiple(iterable $keys): bool
+ {
+ $success = true;
+ foreach ($keys as $key) {
+ $success = $success && $this->delete($key);
+ }
+ return $success;
+ }
+
+ public function has(string $key): bool
+ {
+ $filePath = $this->getFilePath($key);
+ return file_exists($filePath);
+ }
+}
diff --git a/src/Cache/MySqlCache.php b/src/Cache/MySqlCache.php
new file mode 100644
index 0000000..d6768fb
--- /dev/null
+++ b/src/Cache/MySqlCache.php
@@ -0,0 +1,207 @@
+wpdb = $wpdb;
+ $this->tableName = $wpdb->prefix . $dbTableName;
+ $this->defaultTtl = $defaultTtl;
+
+ $this->createCacheTable();
+ }
+
+ /**
+ * Creates the wp_cached_requests table if it does not already exist.
+ */
+ private function createCacheTable(): void
+ {
+ $charsetCollate = $this->wpdb->get_charset_collate();
+ $sql = "CREATE TABLE IF NOT EXISTS {$this->tableName} (
+ `cache_key` VARCHAR(255) NOT NULL PRIMARY KEY,
+ `cache_value` MEDIUMTEXT NOT NULL,
+ `expiration` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ ) $charsetCollate;";
+
+ $this->wpdb->query($sql);
+ }
+
+ /**
+ * Retrieves a value from the cache.
+ *
+ * @param string $key The cache key.
+ * @param mixed $default The default value to return if the key is not found.
+ * @return mixed The cached value, or the default value if not found.
+ */
+ public function get(string $key, mixed $default = null): mixed
+ {
+ $sql = "SELECT cache_value FROM {$this->tableName} WHERE cache_key = %s AND expiration > NOW();";
+ $prepared = $this->wpdb->prepare($sql, $key);
+ $result = $this->wpdb->get_var($prepared);
+
+ return $result !== false ? unserialize($result) : $default;
+ }
+
+ /**
+ * Stores a value in the cache.
+ *
+ * @param string $key The cache key.
+ * @param mixed $value The value to store.
+ * @param null|int|\DateInterval $ttl The time to live for the cached value, in seconds.
+ * @return bool True if the value was stored successfully, false otherwise.
+ */
+ public function set(string $key, mixed $value, null|int|\DateInterval $ttl = 3600): bool
+ {
+ $data = serialize($value);
+ $expiration = Time::getExpireTime($ttl);
+
+ $sql = "INSERT INTO {$this->tableName} (cache_key, cache_value, expiration) VALUES (%s, %s, %s)
+ ON DUPLICATE KEY UPDATE cache_value = %s, expiration = %s;";
+ $prepared = $this->wpdb->prepare($sql, $key, $data, $expiration, $data, $expiration);
+
+ return $this->wpdb->query($prepared) !== false;
+ }
+
+ /**
+ * Deletes a value from the cache.
+ *
+ * @param string $key The cache key.
+ * @return bool True if the value was deleted successfully, false otherwise.
+ */
+ public function delete(string $key): bool
+ {
+ $sql = "DELETE FROM {$this->tableName} WHERE cache_key = %s;";
+ $prepared = $this->wpdb->prepare($sql, $key);
+
+ return $this->wpdb->delete($this->tableName, ['cache_key' => $key]) !== false;
+ }
+
+ /**
+ * Clears the entire cache.
+ *
+ * @return bool True if the cache was cleared successfully, false otherwise.
+ */
+ public function clear(): bool
+ {
+ $sql = "DELETE FROM {$this->tableName};";
+ return $this->wpdb->query($sql) !== false;
+ }
+
+ /**
+ * Retrieves multiple values from the cache.
+ *
+ * @param iterable $keys The cache keys.
+ * @param mixed $default The default value to return if a key is not found.
+ * @return iterable An iterable containing the cached values, or the default value if not found.
+ */
+ public function getMultiple(iterable $keys, mixed $default = null): iterable
+ {
+ $keyList = implode(',', array_map(function ($key) {
+ return $this->wpdb->prepare('%s', $key);
+ }, $keys));
+
+ $sql = "SELECT cache_key, cache_value FROM {$this->tableName} WHERE cache_key IN ($keyList) AND expiration > NOW();";
+ $results = $this->wpdb->get_results($sql, ARRAY_A);
+
+ $cachedValues = [];
+ foreach ($results as $result) {
+ $cachedValues[$result['cache_key']] = unserialize($result['cache_value']);
+ }
+
+ foreach ($keys as $key) {
+ if (!isset($cachedValues[$key])) {
+ $cachedValues[$key] = $default;
+ }
+ }
+
+ return $cachedValues;
+ }
+
+ /**
+ * Stores multiple values in the cache.
+ *
+ * @param iterable $values The values to store.
+ * @param null|int|\DateInterval $ttl The time to live for the cached values, in seconds.
+ * @return bool True if all values were stored successfully, false otherwise.
+ */
+ public function setMultiple(iterable $values, null|int|\DateInterval $ttl = 3600): bool
+ {
+ $sql = "INSERT INTO {$this->tableName} (cache_key, cache_value, expiration) VALUES ";
+ $placeholders = [];
+ $data = [];
+ foreach ($values as $key => $value) {
+ $data[] = serialize($value);
+ $placeholders[] = "(%s, %s, %s)";
+ }
+
+ $expiration = Time::getExpireTime($ttl);
+ $sql .= implode(', ', $placeholders) . " ON DUPLICATE KEY UPDATE cache_value = VALUES(cache_value), expiration = VALUES(expiration);";
+
+ $prepared = $this->wpdb->prepare($sql, array_merge(array_keys($values), $data, array_fill(0, count($values), $expiration)));
+
+ return $this->wpdb->query($prepared) !== false;
+ }
+
+ /**
+ * Deletes multiple values from the cache.
+ *
+ * @param iterable $keys The cache keys.
+ * @return bool True if all values were deleted successfully, false otherwise.
+ */
+ public function deleteMultiple(iterable $keys): bool
+ {
+ $keyList = implode(',', array_map(function ($key) {
+ return $this->wpdb->prepare('%s', $key);
+ }, $keys));
+
+ $sql = "DELETE FROM {$this->tableName} WHERE cache_key IN ($keyList);";
+ return $this->wpdb->query($sql) !== false;
+ }
+
+ /**
+ * Checks if a value exists in the cache.
+ *
+ * @param string $key The cache key.
+ * @return bool True if the value exists, false otherwise.
+ */
+ public function has(string $key): bool
+ {
+ $sql = "SELECT 1 FROM {$this->tableName} WHERE cache_key = %s AND expiration > NOW();";
+ $prepared = $this->wpdb->prepare($sql, $key);
+ return $this->wpdb->get_var($prepared) !== false;
+ }
+}
diff --git a/src/Cache/RedisCache.php b/src/Cache/RedisCache.php
new file mode 100644
index 0000000..415259e
--- /dev/null
+++ b/src/Cache/RedisCache.php
@@ -0,0 +1,105 @@
+redis = new \Redis();
+ $this->redis->connect($host, $port);
+ if (!empty($password)) {
+ $this->redis->auth($password);
+ }
+ $this->redis->select($database);
+ } catch (\RedisException $e) {
+ throw new DriverException('Failed to connect to Redis: ' . $e->getMessage());
+ }
+
+ $this->redis->connect($host, $port);
+ if (!empty($password)) {
+ $this->redis->auth($password);
+ }
+ $this->redis->select($database);
+ }
+
+ public function get(string $key, mixed $default = null): mixed
+ {
+ $value = $this->redis->get($key);
+ return $value !== false ? $value : $default;
+ }
+
+ public function set(string $key, mixed $value, null|int|\DateInterval $ttl = null): bool
+ {
+ if ($ttl !== null) {
+ $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl;
+ return $this->redis->setex($key, $ttl, $value);
+ } else {
+ return $this->redis->set($key, $value);
+ }
+ }
+
+ public function delete(string $key): bool
+ {
+ return $this->redis->del($key) > 0;
+ }
+
+ public function clear(): bool
+ {
+ return $this->redis->flushAll();
+ }
+
+ public function getMultiple(iterable $keys, mixed $default = null): iterable
+ {
+ $values = $this->redis->mget(array_values($keys));
+ $results = [];
+ foreach ($keys as $key => $originalKey) {
+ $results[$originalKey] = isset($values[$key]) ? $values[$key] : $default;
+ }
+ return $results;
+ }
+
+ public function setMultiple(iterable $values, null|int|\DateInterval $ttl = null): bool
+ {
+ $pipeline = $this->redis->pipeline();
+ foreach ($values as $key => $value) {
+ if ($ttl !== null) {
+ $ttl = $ttl instanceof \DateInterval ? $ttl->s : $ttl;
+ $pipeline->setex($key, $ttl, $value);
+ } else {
+ $pipeline->set($key, $value);
+ }
+ }
+ return $pipeline->execute() === array_fill(0, count($values), true);
+ }
+
+ public function deleteMultiple(iterable $keys): bool
+ {
+ return $this->redis->del(array_values($keys)) > 0;
+ }
+
+ public function has(string $key): bool
+ {
+ return $this->redis->exists($key);
+ }
+}
diff --git a/src/Exceptions/DriverException.php b/src/Exceptions/DriverException.php
new file mode 100644
index 0000000..6a8f6f0
--- /dev/null
+++ b/src/Exceptions/DriverException.php
@@ -0,0 +1,8 @@
+cacheExpires = Time::getTimeDiff($cacheExpires);
+ }
+ }
+
+ /**
+ * Adds the cache middleware to the endpoint if a cache instance is provided.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return WP_REST_Response|null The response object if the request is cached, or null otherwise.
+ */
+ private function addCacheProcess(\WP_REST_Request $request) {
+ $cache = $this->cache;
+ $key = md5(implode('.', $request->get_params()));
+ $isCached = $cache->get($key);
+
+ if ($isCached) {
+ return new WP_REST_Response($this->selectiveUnserialize($isCached), 200);
+ }
+ }
+
+ /**
+ * Registers the endpoint with the WordPress REST API.
+ */
+ public function register()
+ {
+ add_action('rest_api_init', function () {
+ register_rest_route($this->namespace, $this->route, [
+ 'methods' => $this->methods,
+ 'callback' => [$this, 'handle'],
+ 'permission_callback' => $this->permissionCallback,
+ 'args' => $this->args,
+ ]);
+ });
+ }
+
+ /**
+ * Adds a middleware callback to the endpoint.
+ *
+ * @param callable $middleware The middleware callback.
+ */
+ public function addMiddleware(callable $middleware)
+ {
+ $this->middlewares[] = $middleware;
+ }
+
+ /**
+ * Handles the endpoint request.
+ *
+ * @param WP_REST_Request $request The request object.
+ * @return WP_REST_Response The response object.
+ */
+ public function handle(WP_REST_Request $request): WP_REST_Response
+ {
+ $response = $process = null;
+
+ if (!is_null($this->cache)) {
+ $response = $this->addCacheProcess($request);
+ }
+
+ foreach ($this->middlewares as $middleware) {
+ $process = $middleware($request);
+
+ if ($process instanceof WP_REST_Response) {
+ return $process;
+ }
+ }
+
+ if (is_null($response)) {
+ $callable = $this->callback;
+ $response = $callable($request);
+ }
+
+ if (is_array($response)) {
+ if (!is_null($this->cache)) {
+ $key = md5(implode('.', $request->get_params()));
+ $this->cache->set($key, $this->selectiveSerialize($response), $this->cacheExpires);
+ }
+ return $this->getResponse($response);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Selectively serializes data based on the configured serialization method.
+ *
+ * @param mixed $data The data to serialize.
+ * @return string The serialized data.
+ */
+ private function selectiveSerialize(mixed $data): string
+ {
+ return match ($this->cacheSerializingMethod) {
+ 0 => serialize($data),
+ 1 => json_encode($data),
+ 2 => igbinary_serialize($data),
+ default => $data,
+ };
+ }
+
+ /**
+ * Selectively unserializes data based on the configured serialization method.
+ *
+ * @param mixed $data The serialized data.
+ * @return mixed The unserialized data.
+ */
+ private function selectiveUnserialize(mixed $data): mixed
+ {
+ return match ($this->cacheSerializingMethod) {
+ 0 => unserialize($data),
+ 1 => json_decode($data, true),
+ 2 => igbinary_unserialize($data),
+ default => $data,
+ };
+ }
+
+ /**
+ * Creates a new WP_REST_Response object.
+ *
+ * @param array $data The data to include in the response.
+ * @param int $status The HTTP status code for the response.
+ * @return WP_REST_Response The response object.
+ */
+ protected function getResponse(array $data, int $status = 200): WP_REST_Response
+ {
+ return new WP_REST_Response($data, $status);
+ }
+}
diff --git a/src/Rest/Middleware/CorsMiddleware.php b/src/Rest/Middleware/CorsMiddleware.php
new file mode 100644
index 0000000..71ecc00
--- /dev/null
+++ b/src/Rest/Middleware/CorsMiddleware.php
@@ -0,0 +1,90 @@
+allowedOrigins = $allowedOrigins;
+ $this->allowedMethods = $allowedMethods;
+ $this->allowedHeaders = $allowedHeaders;
+ $this->maxAge = $maxAge;
+ }
+
+ /**
+ * Middleware invocation handler
+ *
+ * Processes the request and adds appropriate CORS headers to the response.
+ * Handles preflight (OPTIONS) requests automatically.
+ *
+ * @param WP_REST_Request $request The incoming request object
+ * @param callable $next The next middleware in the chain
+ * @return WP_REST_Response The modified response with CORS headers
+ */
+ public function __invoke(WP_REST_Request $request, callable $next): WP_REST_Response
+ {
+ $response = $next($request);
+
+ $origin = $request->get_header('origin');
+
+ if ($origin && ($this->allowedOrigins === ['*'] || in_array($origin, $this->allowedOrigins))) {
+ $response->header('Access-Control-Allow-Origin', $origin);
+ }
+
+ if ($request->get_method() === 'OPTIONS') {
+ $response->header('Access-Control-Allow-Methods', implode(', ', $this->allowedMethods));
+ $response->header('Access-Control-Allow-Headers', implode(', ', $this->allowedHeaders));
+ $response->header('Access-Control-Max-Age', $this->maxAge);
+ }
+
+ $response->header('Access-Control-Allow-Credentials', 'true');
+
+ return $response;
+ }
+}
diff --git a/src/Rest/Middleware/MiddlewareFactory.php b/src/Rest/Middleware/MiddlewareFactory.php
new file mode 100644
index 0000000..dce02eb
--- /dev/null
+++ b/src/Rest/Middleware/MiddlewareFactory.php
@@ -0,0 +1,60 @@
+rules = $rules;
+ $this->stripTags = $stripTags;
+ $this->encodeSpecialChars = $encodeSpecialChars;
+ $this->allowedHtmlTags = $allowedHtmlTags;
+ $this->encoding = $encoding;
+ }
+
+ /**
+ * Middleware invocation handler
+ *
+ * Processes and sanitizes all request parameters according to configuration.
+ *
+ * @param WP_REST_Request $request The incoming request object
+ * @param callable $next The next middleware in the chain
+ * @return WP_REST_Response The response with sanitized parameters
+ */
+ public function __invoke(WP_REST_Request $request, callable $next): WP_REST_Response
+ {
+ $params = $request->get_params();
+ $sanitizedParams = $this->sanitizeData($params);
+
+ foreach ($sanitizedParams as $key => $value) {
+ $request->set_param($key, $value);
+ }
+
+ return $next($request);
+ }
+
+ /**
+ * Recursively sanitizes an array of data
+ *
+ * @param mixed $data The data to sanitize
+ * @param string $path Current path for nested fields
+ * @return mixed The sanitized data
+ */
+ private function sanitizeData($data, $path = '')
+ {
+ if (is_array($data)) {
+ $sanitized = [];
+ foreach ($data as $key => $value) {
+ $currentPath = $path ? "$path.$key" : $key;
+ $sanitized[$key] = $this->sanitizeValue($value, $currentPath);
+ }
+ return $sanitized;
+ }
+
+ return $this->sanitizeValue($data, $path);
+ }
+
+ /**
+ * Sanitizes a single value
+ *
+ * Applies custom rules, strips tags, encodes special characters,
+ * and performs additional sanitization as configured.
+ *
+ * @param mixed $value The value to sanitize
+ * @param string $path Field path for rule matching
+ * @return mixed The sanitized value
+ */
+ private function sanitizeValue($value, string $path)
+ {
+ if (is_array($value)) {
+ return $this->sanitizeData($value, $path);
+ }
+
+ if (!is_string($value)) {
+ return $value;
+ }
+
+ $value = trim($value);
+ $value = wp_check_invalid_utf8($value);
+
+ // Apply custom rules first
+ if (isset($this->rules[$path])) {
+ $value = call_user_func($this->rules[$path], $value);
+ }
+
+ // Strip HTML tags if enabled
+ if ($this->stripTags) {
+ if ($this->allowedHtmlTags) {
+ $value = wp_kses($value, $this->allowedHtmlTags);
+ } else {
+ $value = wp_strip_all_tags($value, true);
+ }
+ }
+
+ // Encode special characters if enabled
+ if ($this->encodeSpecialChars && !$this->stripTags) {
+ $value = htmlspecialchars($value, ENT_QUOTES | ENT_HTML5, $this->encoding, false);
+ }
+
+ return $value;
+ }
+}
diff --git a/src/Rest/RestEndpointFacade.php b/src/Rest/RestEndpointFacade.php
new file mode 100644
index 0000000..ccbe997
--- /dev/null
+++ b/src/Rest/RestEndpointFacade.php
@@ -0,0 +1,71 @@
+register();
+ return $endpoint;
+ }
+}
diff --git a/src/Utilities/Time.php b/src/Utilities/Time.php
new file mode 100644
index 0000000..a6943ff
--- /dev/null
+++ b/src/Utilities/Time.php
@@ -0,0 +1,43 @@
+setTimezone(new \DateTimeZone($timezone));
+ }
+
+ $interval = $now->diff($dateTime);
+ $seconds = $interval->h * 3600 + $interval->i * 60 + $interval->s;
+ return $seconds;
+ }
+
+ /**
+ * Gets the expiration time for a given interval.
+ *
+ * @param \DateInterval|int $interval The interval to add to the current time.
+ * @return string The expiration time in Y-m-d H:i:s format.
+ */
+ public static function getExpireTime(\DateInterval|int $interval)
+ {
+ $now = new \DateTime();
+
+ if (is_numeric($interval)) {
+ $interval = \DateInterval::createFromDateString($interval . ' seconds');
+ }
+
+ $futureDate = $now->add($interval);
+
+ return $now->format('Y-m-d H:i:s');
+ }
+}
\ No newline at end of file
diff --git a/tests/Rest/Middleware/CorsMiddlewareTest.php b/tests/Rest/Middleware/CorsMiddlewareTest.php
new file mode 100644
index 0000000..b76f8af
--- /dev/null
+++ b/tests/Rest/Middleware/CorsMiddlewareTest.php
@@ -0,0 +1,105 @@
+middleware = new CorsMiddleware(
+ allowedOrigins: ['https://example.com'],
+ allowedMethods: ['GET', 'POST'],
+ allowedHeaders: ['Content-Type'],
+ maxAge: 3600
+ );
+
+ $this->request = new WP_REST_Request();
+ $this->response = new WP_REST_Response();
+ }
+
+ public function testAddsCorsHeadersForAllowedOrigin()
+ {
+ // Create a mock for get_header to return our test origin
+ $this->request = $this->getMockBuilder(WP_REST_Request::class)
+ ->onlyMethods(['get_header'])
+ ->getMock();
+
+ $this->request->expects($this->once())
+ ->method('get_header')
+ ->with('origin')
+ ->willReturn('https://example.com');
+
+ $next = fn() => $this->response;
+
+ $result = ($this->middleware)($this->request, $next);
+
+ $headers = $result->get_headers();
+ $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
+ $this->assertEquals('https://example.com', $headers['Access-Control-Allow-Origin']);
+ $this->assertArrayHasKey('Access-Control-Allow-Credentials', $headers);
+ $this->assertEquals('true', $headers['Access-Control-Allow-Credentials']);
+ }
+
+ public function testHandlesPreflightRequest()
+ {
+ // Create a mock for both get_header and get_method
+ $this->request = $this->getMockBuilder(WP_REST_Request::class)
+ ->onlyMethods(['get_header', 'get_method'])
+ ->getMock();
+
+ $this->request->expects($this->once())
+ ->method('get_header')
+ ->with('origin')
+ ->willReturn('https://example.com');
+
+ $this->request->expects($this->once())
+ ->method('get_method')
+ ->willReturn('OPTIONS');
+
+ $next = fn() => $this->response;
+
+ $result = ($this->middleware)($this->request, $next);
+
+ $headers = $result->get_headers();
+ $this->assertArrayHasKey('Access-Control-Allow-Origin', $headers);
+ $this->assertEquals('https://example.com', $headers['Access-Control-Allow-Origin']);
+ $this->assertArrayHasKey('Access-Control-Allow-Methods', $headers);
+ $this->assertEquals('GET, POST', $headers['Access-Control-Allow-Methods']);
+ $this->assertArrayHasKey('Access-Control-Allow-Headers', $headers);
+ $this->assertEquals('Content-Type', $headers['Access-Control-Allow-Headers']);
+ $this->assertArrayHasKey('Access-Control-Max-Age', $headers);
+ $this->assertEquals(3600, $headers['Access-Control-Max-Age']);
+ }
+
+ public function testIgnoresDisallowedOrigin()
+ {
+ $this->request = $this->getMockBuilder(WP_REST_Request::class)
+ ->onlyMethods(['get_header'])
+ ->getMock();
+
+ $this->request->expects($this->once())
+ ->method('get_header')
+ ->with('origin')
+ ->willReturn('https://unauthorized.com');
+
+ $next = fn() => $this->response;
+
+ $result = ($this->middleware)($this->request, $next);
+
+ $headers = $result->get_headers();
+ $this->assertArrayNotHasKey('Access-Control-Allow-Origin', $headers);
+ $this->assertArrayHasKey('Access-Control-Allow-Credentials', $headers);
+ $this->assertEquals('true', $headers['Access-Control-Allow-Credentials']);
+ }
+}
diff --git a/tests/Rest/Middleware/SanitizationMiddlewareTest.php b/tests/Rest/Middleware/SanitizationMiddlewareTest.php
new file mode 100644
index 0000000..fc91ea6
--- /dev/null
+++ b/tests/Rest/Middleware/SanitizationMiddlewareTest.php
@@ -0,0 +1,108 @@
+middleware = new SanitizationMiddleware();
+ $this->request = Mockery::mock(WP_REST_Request::class);
+ }
+
+ public function testSanitizesInputAccordingToRules()
+ {
+ $params = [
+ 'title' => '
TEST TITLE
',
+ 'content' => 'Hello World'
+ ];
+
+ $this->request->shouldReceive('get_params')
+ ->once()
+ ->andReturn($params);
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('title', 'TEST TITLE');
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('content', 'Hello World');
+
+ $response = new WP_REST_Response();
+ $result = ($this->middleware)($this->request, function($request) use ($response) {
+ return $response;
+ });
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testHandlesNestedArrays()
+ {
+ $params = [
+ 'meta' => [
+ 'title' => 'TEST TITLE
',
+ 'description' => 'Hello World'
+ ]
+ ];
+
+ $this->request->shouldReceive('get_params')
+ ->once()
+ ->andReturn($params);
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('meta', [
+ 'title' => 'TEST TITLE',
+ 'description' => 'Hello World'
+ ]);
+
+ $response = new WP_REST_Response();
+ $result = ($this->middleware)($this->request, function($request) use ($response) {
+ return $response;
+ });
+
+ $this->assertSame($response, $result);
+ }
+
+ public function testPreservesNonStringValues()
+ {
+ $params = [
+ 'id' => 123,
+ 'active' => true,
+ 'price' => 99.99
+ ];
+
+ $this->request->shouldReceive('get_params')
+ ->once()
+ ->andReturn($params);
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('id', 123);
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('active', true);
+
+ $this->request->shouldReceive('set_param')
+ ->once()
+ ->with('price', 99.99);
+
+ $response = new WP_REST_Response();
+ $result = ($this->middleware)($this->request, function($request) use ($response) {
+ return $response;
+ });
+
+ $this->assertSame($response, $result);
+ }
+}
diff --git a/tests/Stubs/WP_REST_Request.php b/tests/Stubs/WP_REST_Request.php
new file mode 100644
index 0000000..557f82e
--- /dev/null
+++ b/tests/Stubs/WP_REST_Request.php
@@ -0,0 +1,27 @@
+headers[$key] = $value;
+ }
+
+ public function get_headers()
+ {
+ return $this->headers;
+ }
+}
diff --git a/tests/bootstrap.php b/tests/bootstrap.php
new file mode 100644
index 0000000..531e2f9
--- /dev/null
+++ b/tests/bootstrap.php
@@ -0,0 +1,46 @@
+alias(function($string, $keep_line_breaks = false) {
+ // Remove script tags and their content first
+ $string = preg_replace('/