diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..e69de29 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..de6682b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,40 @@ +name: CI +on: + push: + branches: + - master + - develop + - alpha + - beta + +jobs: + release: + name: Release + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.OBLAKBOT_PAT }} + - name: Import GPG key + uses: crazy-max/ghaction-import-gpg@v6 + id: gpg + with: + gpg_private_key: ${{ secrets.OBLAKBOT_GPG_KEY }} + passphrase: ${{ secrets.OBLAKBOT_GPG_PASS }} + git_config_global: true + git_user_signingkey: true + git_commit_gpgsign: true + - name: Semantic Release + uses: cycjimmy/semantic-release-action@v4 + with: + extra_plugins: | + @semantic-release/github + @semantic-release/exec + env: + GIT_AUTHOR_NAME: ${{ steps.gpg.outputs.name}} + GIT_AUTHOR_EMAIL: ${{ steps.gpg.outputs.email}} + GIT_COMMITTER_NAME: ${{ steps.gpg.outputs.name}} + GIT_COMMITTER_EMAIL: ${{ steps.gpg.outputs.email}} + GITHUB_TOKEN: ${{ secrets.OBLAKBOT_PAT }} diff --git a/.releaserc b/.releaserc index 4f662fb..b113a63 100644 --- a/.releaserc +++ b/.releaserc @@ -20,7 +20,7 @@ [ "@semantic-release/exec", { - "prepareCmd": "zip -r '/tmp/release.zip' ./src README.md" + "prepareCmd": "zip -r '/tmp/release.zip' ./src README.md ./composer.json" } ], [ @@ -29,8 +29,8 @@ "assets": [ { "path": "/tmp/release.zip", - "name": "xwp-hook-invoker-${nextRelease.version}.zip", - "label": "xWP Hook Invoker v${nextRelease.version}" + "name": "xwp-di-v${nextRelease.version}.zip", + "label": "xWP Dependency Injection v${nextRelease.version}" } ] } diff --git a/README.md b/README.md index 40913f9..6c44ae2 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,120 @@
-

WordPress Hook Dependency Injection

+

XWP-DI

+

Dependency Injection Container for WordPress

-![Packagist Version](https://img.shields.io/packagist/v/oblak/wp-hook-di) -![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/oblak/wp-hook-di/php) -[![semantic-release: angular](https://img.shields.io/badge/semantic--release-angular-e10079?logo=semantic-release)](https://github.com/semantic-release/semantic-release) +[![Packagist Version](https://img.shields.io/packagist/v/x-wp/di?label=Release&style=flat-square)](https://packagist.org/packages/x-wp/di) +![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/x-wp/di/php?label=PHP&logo=php&logoColor=white&logoSize=auto&style=flat-square) +![Static Badge](https://img.shields.io/badge/WP-%3E%3D6.4-3858e9?style=flat-square&logo=wordpress&logoSize=auto) +[![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/x-wp/di/release.yml?label=Build&event=push&style=flat-square&logo=githubactions&logoColor=white&logoSize=auto)](https://github.com/x-wp/di/actions/workflows/release.yml)
+ +This library allows you to implement [dependency injection design pattern](https://en.wikipedia.org/wiki/Dependency_injection) in your WordPress plugin or theme. It provides a simple and easy-to-use interface to manage dependencies and hook callbacks. + +## Key Features + +1. Reliable - Powered by [PHP-DI](https://php-di.org/), a mature and feature-rich dependency injection container. +2. Interoperable - Provides PSR-11 compliant container interface. +3. Easy to use - Reduces the boilerplate code required to manage dependencies and hook callbacks. +4. Customizable - Allows various configuration options to customize the container behavior. +5. Flexible - Enables advanced hook callback mechanisms. +6. Fast - Dependencies are resolved only when needed, and the container can be compiled for better performance. + +## Installation + +You can install this package via composer: + +```bash +composer require x-wp/di +``` + +> [!TIP] +> We recommend using the `automattic/jetpack-autoloader` with this package to prevent autoloading issues. + +## Usage + +Below is a simple example to demonstrate how to use this library in your plugin or theme. + +### Creating the Application and Container + +You will need a class which will be used as the entry point for your plugin/theme. This class must have a `#[Module]` attribute to define the container configuration. + +```php + + */ + public static function configure(): array { + return array( + 'my.def' => \DI\value('my value'), + ); + } +} +``` + +After defining the module, you can create the application using the `xwp_create_app` function. + +```php + 'my-plugin', + 'module' => My_Plugin::class, + 'compile' => false, + ); +); + +``` + +### Using handlers and callbacks + +Handler is any class which is annotated with a `#[Handler]` attribute. Class methods can be annotated with `#[Action]` or `#[Filter]` attributes to define hook callbacks. + +```php +=8.0", "automattic/jetpack-constants": "^2", "php-di/php-di": "^7", + "symfony/polyfill-php81": "^1.31", "x-wp/helper-classes": "^1.13", "x-wp/helper-functions": "^1.13" }, @@ -36,8 +37,15 @@ "swissspidy/phpstan-no-private": "^0.2", "szepeviktor/phpstan-wordpress": "^1.3" }, + "conflict": { + "oblak/wp-hook-di": "*" + }, "provide": { - "x-wp/di-implementation": "self.version" + "psr/container-implementation": "^1.0", + "x-wp/di-implementation": "^1.0" + }, + "replace": { + "x-wp/hook-invoker": "*" }, "suggest": { "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package." @@ -52,14 +60,14 @@ ] }, "config": { - "platform": { - "php": "8.0" - }, "allow-plugins": { "automattic/jetpack-autoloader": true, "dealerdirect/phpcodesniffer-composer-installer": true, "phpstan/extension-installer": true }, + "platform": { + "php": "8.0" + }, "sort-packages": true } } diff --git a/composer.lock b/composer.lock index 275f525..04aecd0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "4615a7dffb243c8eb4bd73cc999cc69f", + "content-hash": "3879411c4b4bea5e32cc13d31fdcb283", "packages": [ { "name": "automattic/jetpack-constants", @@ -299,9 +299,85 @@ }, "time": "2021-11-05T16:47:00+00:00" }, + { + "name": "symfony/polyfill-php81", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php81.git", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php81\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php81/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, { "name": "x-wp/helper-classes", - "version": "v1.13.4", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/x-wp/helper-classes.git", @@ -351,22 +427,22 @@ ], "support": { "issues": "https://github.com/x-wp/helper-classes/issues", - "source": "https://github.com/x-wp/helper-classes/tree/v1.13.4" + "source": "https://github.com/x-wp/helper-classes/tree/v1.18.0" }, "time": "2024-09-23T14:31:15+00:00" }, { "name": "x-wp/helper-functions", - "version": "v1.13.4", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/x-wp/helper-functions.git", - "reference": "93f6c928cd08192298e572a49873e3bd6b7aad49" + "reference": "1e3392e49d0fe95eb13e8980081b9ceb0268e0bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/x-wp/helper-functions/zipball/93f6c928cd08192298e572a49873e3bd6b7aad49", - "reference": "93f6c928cd08192298e572a49873e3bd6b7aad49", + "url": "https://api.github.com/repos/x-wp/helper-functions/zipball/1e3392e49d0fe95eb13e8980081b9ceb0268e0bd", + "reference": "1e3392e49d0fe95eb13e8980081b9ceb0268e0bd", "shasum": "" }, "require": { @@ -378,8 +454,10 @@ "type": "library", "autoload": { "files": [ - "xwp-helper-fns.php", - "xwp-helper-fns-req.php" + "xwp-helper-fns-arr.php", + "xwp-helper-fns-num.php", + "xwp-helper-fns-req.php", + "xwp-helper-fns.php" ], "psr-4": { "XWP\\Helper\\Functions\\": "." @@ -407,13 +485,13 @@ ], "support": { "issues": "https://github.com/x-wp/helper-functions/issues", - "source": "https://github.com/x-wp/helper-functions/tree/v1.13.4" + "source": "https://github.com/x-wp/helper-functions/tree/v1.18.0" }, - "time": "2024-09-23T14:26:03+00:00" + "time": "2024-10-29T22:53:16+00:00" }, { "name": "x-wp/helper-traits", - "version": "v1.13.4", + "version": "v1.18.0", "source": { "type": "git", "url": "https://github.com/x-wp/helper-traits.git", @@ -459,7 +537,7 @@ ], "support": { "issues": "https://github.com/x-wp/helper-traits/issues", - "source": "https://github.com/x-wp/helper-traits/tree/v1.13.4" + "source": "https://github.com/x-wp/helper-traits/tree/v1.18.0" }, "time": "2024-09-18T12:43:44+00:00" } @@ -1020,16 +1098,16 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.32.0", + "version": "1.33.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4" + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/6ca22b154efdd9e3c68c56f5d94670920a1c19a4", - "reference": "6ca22b154efdd9e3c68c56f5d94670920a1c19a4", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", "shasum": "" }, "require": { @@ -1061,22 +1139,22 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.32.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" }, - "time": "2024-09-26T07:23:32+00:00" + "time": "2024-10-13T11:25:22+00:00" }, { "name": "phpstan/phpstan", - "version": "1.12.5", + "version": "1.12.7", "source": { "type": "git", "url": "https://github.com/phpstan/phpstan.git", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17" + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", - "reference": "7e6c6cb7cecb0a6254009a1a8a7d54ec99812b17", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", + "reference": "dc2b9976bd8b0f84ec9b0e50cc35378551de7af0", "shasum": "" }, "require": { @@ -1121,7 +1199,7 @@ "type": "github" } ], - "time": "2024-09-26T12:45:22+00:00" + "time": "2024-10-18T11:12:07+00:00" }, { "name": "phpstan/phpstan-deprecation-rules", diff --git a/src/Builder.php b/src/App_Builder.php similarity index 65% rename from src/Builder.php rename to src/App_Builder.php index fbd9d8d..fe160dc 100644 --- a/src/Builder.php +++ b/src/App_Builder.php @@ -17,17 +17,15 @@ * * @extends \DI\ContainerBuilder */ -class Builder extends \DI\ContainerBuilder { +class App_Builder extends \DI\ContainerBuilder { /** * Static method to configure the container. * * @param array $config Configuration options. - * @return Builder + * @return App_Builder */ - public static function configure( array $config = array() ): Builder { - $config = static::getDefaultConfig( $config ); - - return ( new Builder() ) + public static function configure( array $config = array() ): App_Builder { + return ( new App_Builder() ) ->useAttributes( $config['attributes'] ) ->useAutowiring( $config['autowiring'] ) ->writeProxiesToFile( writeToFile: $config['proxies'], proxyDirectory: $config['compile_dir'] ) @@ -38,26 +36,6 @@ public static function configure( array $config = array() ): Builder { ); } - /** - * Get the default configuration. - * - * @param array $config Configuration options. - * @return array - */ - protected static function getDefaultConfig( array $config ): array { - return \wp_parse_args( - $config, - array( - 'attributes' => true, - 'autowiring' => true, - 'compile' => 'production' === \wp_get_environment_type(), - 'compile_class' => 'CompiledContainer' . \strtoupper( $config['id'] ), - 'compile_dir' => __DIR__ . '/cache', - 'proxies' => false, - ), - ); - } - //phpcs:ignore Squiz.Commenting.FunctionComment.Missing public function enableCompilation( string $directory, @@ -65,10 +43,16 @@ public function enableCompilation( string $containerParentClass = CompiledContainer::class, bool $compile = true, ): static { + if ( ! $compile ) { + return $this; + } + + if ( ! \is_dir( $directory ) && ! \wp_mkdir_p( $directory ) ) { + return $this; + } + // @phpstan-ignore return.type - return $compile - ? parent::enableCompilation( $directory, $containerClass, $containerParentClass ) - : $this; + return parent::enableCompilation( $directory, $containerClass, $containerParentClass ); } /** @@ -80,7 +64,7 @@ public function enableCompilation( * @return $this */ public function addDefinitions( string|array|DefinitionSource ...$definitions ): static { - return \class_exists( $definitions[0] ) + return \is_string( $definitions[0] ) && \class_exists( $definitions[0] ) ? parent::addDefinitions( \xwp_register_module( $definitions[0] )->get_definitions() ) : parent::addDefinitions( ...$definitions ); } diff --git a/src/App_Factory.php b/src/App_Factory.php index 8bc39a1..34e13ef 100644 --- a/src/App_Factory.php +++ b/src/App_Factory.php @@ -14,8 +14,10 @@ /** * Create and manage DI containers. * - * @method static Container create(array $config) Create a new container. - * @method static Container get(string $container_id) Get a container instance. + * @method static Container create( array $config) Create a new container. + * @method static Container get( string $id ) Get a container instance. + * @method static void extend( string $container, array $module, string $position, ?string $target ) Extend an application container definition. + * @method static bool decompile( string $id, bool $now ) Decompile a container. */ final class App_Factory { use Singleton; @@ -51,11 +53,41 @@ protected function call_create( array $config ): Container { return $this->containers[ $config['id'] ]; } - return $this->containers[ $config['id'] ] ??= Builder::configure( $config ) + $config = $this->parse_config( $config ); + + return $this->containers[ $config['id'] ] ??= App_Builder::configure( $config ) ->addDefinitions( $config['module'] ) + ->addDefinitions( array( 'xwp.app.config' => $config ) ) ->build(); } + /** + * Extend an application container definition. + * + * @param string $container Container ID. + * @param array $module Module classname or array of module classnames. + * @param 'before'|'after' $position Position to insert the module. + * @param string|null $target Target module to extend. + */ + protected function call_extend( string $container, array $module, string $position = 'after', ?string $target = null ): void { + \add_filter( + "xwp_extend_import_{$container}", + static function ( array $imports, string $classname ) use( $module, $position, $target ): array { + if ( $target && $target !== $classname ) { + return $imports; + } + + $params = 'after' === $position + ? array( $imports, $module ) + : array( $module, $imports ); + + return \array_merge( ...$params ); + }, + 10, + 2, + ); + } + /** * Get a container instance. * @@ -65,4 +97,44 @@ protected function call_create( array $config ): Container { protected function call_get( string $id ): Container { return $this->containers[ $id ]; } + + /** + * Decompile a container. + * + * @param string $id Container ID. + * @param bool $now Decompile now or on shutdown. + * @return bool + */ + protected function call_decompile( string $id, bool $now = false ): bool { + $config = $this->containers[ $id ]->get( 'xwp.app.config' ); + + if ( ! $config['compile'] || ! \xwp_wpfs()->is_dir( $config['compile_dir'] ) ) { + return false; + } + + $cb = static fn() => \xwp_wpfs()->rmdir( $config['compile_dir'], true ); + + // @phpstan-ignore return.void + return ! $now ? \add_action( 'shutdown', $cb ) : $cb(); + } + + /** + * Get the default configuration. + * + * @param array $config Configuration options. + * @return array + */ + protected function parse_config( array $config ): array { + return \wp_parse_args( + $config, + array( + 'attributes' => true, + 'autowiring' => true, + 'compile' => 'production' === \wp_get_environment_type(), + 'compile_class' => 'CompiledContainer' . \strtoupper( $config['id'] ), + 'compile_dir' => \WP_CONTENT_DIR . '/cache/xwp-di/' . $config['id'], + 'proxies' => false, + ), + ); + } } diff --git a/src/Core/REST_Controller.php b/src/Core/REST_Controller.php new file mode 100644 index 0000000..cad1955 --- /dev/null +++ b/src/Core/REST_Controller.php @@ -0,0 +1,57 @@ +namespace = $namespace; + + return $this; + } + + /** + * Set the basename. + * + * @param string $base Basename. + * @return static + */ + public function with_basename( string $base ): static { + $this->rest_base = $base; + + return $this; + } + + /** + * Register routes for this controller. + * + * @return void + */ + public function register_routes() { + do_action( $this->namespace . '/' . $this->rest_base ); + } +} diff --git a/src/Decorators/Action.php b/src/Decorators/Action.php index 65a0b2a..abb325c 100644 --- a/src/Decorators/Action.php +++ b/src/Decorators/Action.php @@ -8,11 +8,14 @@ namespace XWP\DI\Decorators; +use XWP\DI\Interfaces\Can_Handle; + /** * Action hook decorator. * * @template T of object - * @extends Filter + * @template H of Can_Handle + * @extends Filter * * @since 1.0.0 */ diff --git a/src/Decorators/Ajax_Action.php b/src/Decorators/Ajax_Action.php new file mode 100644 index 0000000..2265d08 --- /dev/null +++ b/src/Decorators/Ajax_Action.php @@ -0,0 +1,230 @@ + + * @extends Action + */ +#[\Attribute( \Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD )] +class Ajax_Action extends Action { + public const AJAX_GET = 'GET'; + + public const AJAX_POST = 'POST'; + + public const AJAX_REQ = 'REQ'; + + /** + * Action name + * + * @var string + */ + protected string $action; + + /** + * Prefix for the action name. + * + * @var string + */ + protected string $prefix; + + /** + * Ajax method. + * + * @var 'GET'|'POST'|'REQ' + */ + protected string $method; + + /** + * Nonce query var. + * + * @var bool|string + */ + protected bool|string $nonce; + + /** + * Capability required to perform the action. + * + * @var string + */ + protected ?string $cap; + + /** + * Variables to fetch. + * + * @var array + */ + protected array $vars; + + /** + * Ajax hooks. + * + * Can contain private/public ajax hook, or both. + * + * @var array + */ + protected array $hooks; + + /** + * Variable getter function + * + * @var Closure(string, string): string + */ + protected Closure $getter; + + /** + * Constructor. + * + * @param string $action Ajax action name. + * @param string $prefix Prefix for the action name. + * @param bool $public Whether the action is public or not. + * @param 'GET'|'POST'|'REQ' $method Method to fetch the variable. GET, POST, or REQ. + * @param bool|string $nonce String defines the query var for nonce, true checks the default vars, false disables nonce check. + * @param string $cap Capability required to perform the action. + * @param array $vars Variables to fetch. + * @param array $params Parameters to pass to the callback. Will be resolved by the container. + * @param int $priority Hook priority. + */ + public function __construct( + string $action, + string $prefix, + bool $public = true, + string $method = self::AJAX_REQ, + bool|string $nonce = false, + ?string $cap = null, + array $vars = array(), + array $params = array(), + int $priority = 10, + ) { + $this->action = $action; + $this->prefix = $prefix; + $this->method = $method; + $this->nonce = $nonce; + $this->cap = $cap; + $this->vars = $vars; + $this->hooks = $public ? array( 'wp_ajax_nopriv', 'wp_ajax' ) : array( 'wp_ajax' ); + $this->getter = Closure::fromCallable( $this->get_fetch_cb( $method ) ); + + parent::__construct( + tag: '%s_%s_%s', + priority:$priority, + context: self::CTX_AJAX, + conditional: '__return_true', + modifiers: array( '%s', \rtrim( $prefix, '_' ), $action ), + invoke: self::INV_PROXIED, + args: 0, + params: $params, + ); + } + + /** + * Check if the action can be loaded. + * + * @return bool + */ + public function can_load(): bool { + return parent::can_load() && $this->handler->loaded; + } + + /** + * Get the variable fetch callback. + * + * @param 'GET'|'POST'|'REQ' $method Method to fetch the variable. + * @return Closure(string, mixed): mixed + */ + protected function get_fetch_cb( string $method ): Closure { + $cb = match ( $method ) { + 'GET' => 'xwp_fetch_get_var', + 'POST' => 'xwp_fetch_post_var', + 'REQ' => 'xwp_fetch_req_var', + }; + + return Closure::fromCallable( $cb ); + } + + /** + * Loads the hook. + * + * @param ?string $tag Optional hook tag. + * @return bool + */ + protected function load_hook( ?string $tag = null ): bool { + foreach ( $this->hooks as $hook ) { + parent::load_hook( $this->define_tag( $this->tag, array( $hook ) ) ); + } + + return true; + } + + /** + * Fire the hook. + * + * @param mixed ...$args Arguments to pass to the callback. + * @return mixed + */ + protected function fire_hook( mixed ...$args ): mixed { + if ( $this->nonce && ! $this->nonce_check() ) { + $this->fire_guard_cb( 'nonce' ); + } + + if ( $this->cap && ! $this->cap_check() ) { + $this->fire_guard_cb( 'cap' ); + } + + return parent::fire_hook( ...$args ); + } + + /** + * Get the arguments to pass to the callback. + * + * @param array $args Existing arguments. + * @return array + */ + protected function get_cb_args( array $args ): array { + if ( isset( $this->vars['body'] ) ) { + $args[] = \json_decode( \file_get_contents( 'php://input' ), true ) ?? array(); + } + + foreach ( \xwp_array_diff_assoc( $this->vars, 'body' ) as $k => $d ) { + $args[] = ( $this->getter )( $k, $d ); + } + + return parent::get_cb_args( $args ); + } + + private function fire_guard_cb( string $type ): void { + $methods = array( "{$this->action}_guard", 'unverified_call', 'invalid_call' ); + + foreach ( $methods as $method ) { + if ( ! \method_exists( $this->handler->classname, $method ) ) { + continue; + } + + $this->container->call( array( $this->handler->classname, $method ), array( $type ) ); + return; + } + + \wp_die( \esc_html( $type ) ); + } + + private function nonce_check(): bool { + $query_arg = \is_string( $this->nonce ) ? $this->nonce : false; + + return \check_ajax_referer( "{$this->prefix}_{$this->action}", $query_arg, false ); + } + + private function cap_check(): bool { + return \current_user_can( $this->cap ); + } +} diff --git a/src/Decorators/Ajax_Handler.php b/src/Decorators/Ajax_Handler.php new file mode 100644 index 0000000..a0dc415 --- /dev/null +++ b/src/Decorators/Ajax_Handler.php @@ -0,0 +1,41 @@ + + */ +#[\Attribute( \Attribute::TARGET_CLASS )] +class Ajax_Handler extends Handler { + /** + * Constructor + * + * @param string $container Container ID. + * @param int $priority Handler priority. + * @param null|Closure|string|array{class-string,string} $conditional Conditional callback. + */ + public function __construct( + string $container, + int $priority = 10, + array|string|Closure|null $conditional = null, + ) { + parent::__construct( + tag: 'admin_init', + priority: $priority, + container: $container, + context: self::CTX_AJAX, + conditional: $conditional, + ); + } +} diff --git a/src/Decorators/Filter.php b/src/Decorators/Filter.php index fee8ad5..cd825db 100644 --- a/src/Decorators/Filter.php +++ b/src/Decorators/Filter.php @@ -19,15 +19,16 @@ * Filter hook decorator. * * @template T of object + * @template H of Can_Handle * @extends Hook - * @implements Can_Invoke + * @implements Can_Invoke */ #[\Attribute( \Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD )] class Filter extends Hook implements Can_Invoke { /** * The handler. * - * @var Can_Handle + * @var H */ protected Can_Handle $handler; @@ -99,7 +100,7 @@ public function with_reflector( Reflector $r ): static { /** * Set the handler. * - * @param Can_Handle $handler The handler. + * @param H $handler The handler. * @return static */ public function with_handler( Can_Handle $handler ): static { @@ -160,19 +161,30 @@ public function load(): bool { return false; } - $this->loaded = ( "add_{$this->get_type()}" )( - $this->tag, + $this->loaded = $this->load_hook(); + + return $this->loaded; + } + + /** + * Loads the hook. + * + * @param ?string $tag Optional hook tag. + * @return bool + */ + protected function load_hook( ?string $tag = null ): bool { + return ( "add_{$this->get_type()}" )( + $tag ?? $this->tag, $this->target, $this->priority, $this->args, ); - - return $this->loaded; } public function invoke( mixed ...$args ): mixed { if ( ! $this->init_handler( $this->handler::INIT_JUST_IN_TIME ) || + ! parent::can_load() || ( $this->cb_valid( self::INV_ONCE ) && $this->fired ) || ( $this->cb_valid( self::INV_LOOPED ) && $this->firing ) ) { @@ -198,15 +210,28 @@ public function invoke( mixed ...$args ): mixed { protected function fire_hook( mixed ...$args ): mixed { $this->firing = true; + return $this->container->call( + array( $this->handler->classname, $this->method ), + $this->get_cb_args( $args ), + ); + } + + /** + * Get the arguments to pass to the callback. + * + * @param array $args Existing arguments. + * @return array + */ + protected function get_cb_args( array $args ): array { if ( $this->params ) { foreach ( $this->params as $param ) { $args[] = $this->container->get( $param ); } - } - $args[] = $this; + $args[] = $this; + } - return $this->container->call( array( $this->handler->classname, $this->method ), $args ); + return $args; } /** diff --git a/src/Decorators/Handler.php b/src/Decorators/Handler.php index ea794ed..752205d 100644 --- a/src/Decorators/Handler.php +++ b/src/Decorators/Handler.php @@ -54,6 +54,13 @@ class Handler extends Hook implements Can_Handle { */ protected string $container_id; + /** + * Is the handler hookable. + * + * @var bool + */ + protected bool $hookable; + /** * Constructor. * @@ -64,6 +71,7 @@ class Handler extends Hook implements Can_Handle { * @param null|Closure|string|array{class-string,string} $conditional Conditional callback. * @param array|string|false $modifiers Values to replace in the tag name. * @param string $strategy Initialization strategy. + * @param bool $hookable Is the handler hookable. */ public function __construct( ?string $tag = null, @@ -73,10 +81,12 @@ public function __construct( array|string|Closure|null $conditional = null, string|array|false $modifiers = false, string $strategy = self::INIT_DEFFERED, + bool $hookable = true, ) { $this->strategy = $strategy; $this->loaded = self::INIT_DYNAMICALY === $strategy; $this->container_id = $container; + $this->hookable = $hookable; parent::__construct( $tag, $tag ? $priority : null, $context, $conditional, $modifiers ); } @@ -94,7 +104,9 @@ public function with_classname( string $classname ): static { } public function with_container( ?string $container ): static { - $this->container_id ??= $container; + if ( null !== $container ) { + $this->container_id ??= $container; + } return $this; } @@ -110,6 +122,10 @@ public function with_target( object $instance ): static { $this->classname ??= $instance::class; $this->loaded = true; + if ( ! $this->container->has( $this->classname ) ) { + $this->container->set( $this->classname, $this->instance ); + } + return $this; } @@ -127,11 +143,20 @@ public function load(): bool { return false; } - $this->instance ??= $this->container->get( $this->classname ); + $this->instance ??= $this->initialize(); return $this->on_initialize(); } + /** + * Initialize the handler. + * + * @return T + */ + protected function initialize(): object { + return $this->container->get( $this->classname ); + } + /** * Mark the handler as loaded, and call the on_initialize method. * @@ -141,7 +166,7 @@ protected function on_initialize(): bool { $this->loaded = true; if ( \method_exists( $this->classname, 'on_initialize' ) ) { - $this->target->on_initialize(); + $this->container->call( array( $this->classname, 'on_initialize' ) ); } return $this->loaded; @@ -157,7 +182,7 @@ public function get_target(): ?object { } public function can_load(): bool { - return parent::can_load() && $this->check_method( array( $this->classname, 'can_loadialize' ) ); + return parent::can_load() && $this->check_method( array( $this->classname, 'can_initialize' ) ); } protected function get_id(): string { @@ -189,4 +214,8 @@ protected function get_lazy_hook(): string { public function is_lazy(): bool { return self::INIT_ON_DEMAND === $this->strategy || self::INIT_JUST_IN_TIME === $this->strategy; } + + public function is_hookable(): bool { + return $this->hookable; + } } diff --git a/src/Decorators/Hook.php b/src/Decorators/Hook.php index 6b32aad..51ce2d2 100644 --- a/src/Decorators/Hook.php +++ b/src/Decorators/Hook.php @@ -12,9 +12,8 @@ use DI\Container; use ReflectionClass; use ReflectionMethod; -use XWP\DI\Hook\Context; +use XWP\DI\Hook_Context; use XWP\DI\Interfaces\Can_Hook; -use XWP\DI\Traits\Hookable; /** * Base hook from which the action and filter decorators inherit. @@ -112,7 +111,7 @@ public function can_load(): bool { * @return bool */ public function check_context(): bool { - return Context::is_valid_context( $this->context ); + return Hook_Context::validate( $this->context ); } /** @@ -122,7 +121,7 @@ public function check_context(): bool { * @return bool */ protected function check_method( array|string|\Closure|null $method ): bool { - return ! \is_callable( $method ) || $method( $this ); + return ! \is_callable( $method ) || $this->container->call( $method ); } /** diff --git a/src/Decorators/Module.php b/src/Decorators/Module.php index 7d72f2d..d7d45b0 100644 --- a/src/Decorators/Module.php +++ b/src/Decorators/Module.php @@ -28,11 +28,12 @@ class Module extends Handler { /** * Constructor. * - * @param string $container Container ID. - * @param string $hook Hook name. - * @param int $priority Hook priority. - * @param array $imports Array of submodules to import. - * @param array $handlers Array of handlers to register. + * @param string $container Container ID. + * @param string $hook Hook name. + * @param int $priority Hook priority. + * @param array $imports Array of submodules to import. + * @param array $handlers Array of handlers to register. + * @param bool $extendable Is the module extendable. */ public function __construct( string $container, @@ -50,6 +51,12 @@ public function __construct( * @var array */ protected array $handlers = array(), + /** + * Is the module extendable? + * + * @var bool + */ + protected bool $extendable = false, ) { parent::__construct( tag: $hook, @@ -82,7 +89,7 @@ protected function on_initialize(): bool { public function get_definitions(): array { $definitions = $this->get_definition(); - foreach ( $this->imports as $import ) { + foreach ( $this->get_imports() as $import ) { $module = $this->imported ? \xwp_get_module( $import ) : \xwp_register_module( $import ); $definitions = \array_merge( $definitions, $module->get_definitions() ); @@ -93,6 +100,30 @@ public function get_definitions(): array { return $definitions; } + /** + * Get the module imports. + * + * @return array + */ + protected function get_imports(): array { + if ( ! $this->extendable ) { + return $this->imports; + } + + $tag = "xwp_extend_import_{$this->container_id}"; + + /** + * Filter the module imports. + * + * @param array $imports Array of submodules to import. + * @param class-string $classname Module classname. + * @return array + * + * @since 1.0@beta.8 + */ + return \apply_filters( $tag, $this->imports, $this->classname ); + } + /** * Get the module definition. * diff --git a/src/Decorators/REST_Handler.php b/src/Decorators/REST_Handler.php new file mode 100644 index 0000000..6fe3f53 --- /dev/null +++ b/src/Decorators/REST_Handler.php @@ -0,0 +1,95 @@ + + */ +#[\Attribute( \Attribute::TARGET_CLASS )] +class REST_Handler extends Handler { + /** + * Constructor + * + * @param string $namespace REST namespace. + * @param string $basename REST basename. + * @param string $container Container ID. + * @param int $priority Handler priority. + */ + public function __construct( + protected string $namespace, + protected string $basename, + string $container, + int $priority = 10, + ) { + parent::__construct( + tag: 'rest_api_init', + priority: $priority, + container: $container, + context: self::CTX_REST, + ); + } + + /** + * Can the handler be loaded? + * + * Checks if the REST namespace matches the requested route. + * + * @return bool + */ + public function can_load(): bool { + return parent::can_load() && \xwp_can_load_rest_ns( $this->namespace ); + } + + /** + * Initialize the handler. + * + * Sets the namespace and basename. + * + * @return object + */ + protected function initialize(): object { + return parent::initialize() + ->with_namespace( $this->namespace ) + ->with_basename( $this->basename ); + } + + /** + * Get the REST namespace. + * + * @return string + */ + protected function get_namespace(): string { + return $this->namespace; + } + + /** + * Get the REST basename. + * + * @return string + */ + protected function get_basename(): string { + return $this->basename; + } + + /** + * Get the REST hook. + * + * @return string + */ + public function get_rest_hook(): string { + return $this->namespace . '/' . $this->basename; + } +} diff --git a/src/Decorators/REST_Route.php b/src/Decorators/REST_Route.php new file mode 100644 index 0000000..599b0fd --- /dev/null +++ b/src/Decorators/REST_Route.php @@ -0,0 +1,181 @@ + + * @extends Action + */ +#[\Attribute( \Attribute::IS_REPEATABLE | \Attribute::TARGET_METHOD )] +class REST_Route extends Action { + /** + * REST Route arguments. + * + * @var string|array + */ + protected array|string $route_args; + + /** + * REST Route guard. + * + * @var string + */ + protected string $route_guard; + + /** + * REST Route endpoint. + * + * @var string + */ + protected string $endpoint; + + /** + * REST Route methods. + * + * @var string + */ + protected string $methods; + + /** + * Constructor. + * + * @param string $route REST route. + * @param string $methods HTTP methods. + * @param string|array $params Route parameters. + * @param string|null $guard Route guard. + */ + public function __construct( + string $route, + string $methods, + string|array $params = array(), + ?string $guard = null, + ) { + parent::__construct( + tag: 'rest_api_init', + priority: 10, + context: self::CTX_REST, + invoke:self::INV_PROXIED, + ); + + $this->endpoint = $route; + $this->route_args = $params; + $this->methods = $methods; + $this->route_guard = $guard ?? '__return_true'; + } + + /** + * Set the handler instance. + * + * @param H $handler Handler instance. + * @return static + */ + public function with_handler( Can_Handle $handler ): static { + return parent::with_handler( $handler ) + ->with_tag( $handler->rest_hook ) + ->with_priority( $handler->priority + 1 ); + } + + /** + * Set the route priority. + * + * @param int $priority Priority. + * @return static + */ + protected function with_priority( int $priority ): static { + $this->prio = $priority; + + return $this; + } + + /** + * Set the route tag. + * + * @param string $tag Tag. + * @return static + */ + protected function with_tag( string $tag ): static { + $this->tag = $tag; + + return $this; + } + + /** + * Register the REST route. + * + * @param mixed ...$args Arguments. + * @return mixed + */ + public function invoke( mixed ...$args ): mixed { + return \register_rest_route( + $this->handler->namespace, + $this->route, + array( + 'args' => $this->vars, + 'callback' => $this->callback, + 'methods' => $this->methods, + 'permission_callback' => $this->guard, + ), + ); + } + + /** + * Get the route. + * + * @return string + */ + protected function get_route(): string { + return $this->endpoint + ? "/{$this->handler->basename}/{$this->endpoint}" + : "/{$this->handler->basename}"; + } + + /** + * Get the route parameters. + * + * @return array + */ + protected function get_vars(): array { + if ( \is_array( $this->route_args ) ) { + return $this->route_args; + } + + return $this->handler->target->{$this->route_args}( $this->methods ); + } + + /** + * Get the route callback. + * + * @return array{0:T, 1: string} + */ + protected function get_callback(): array { + return array( $this->handler->target, $this->method ); + } + + /** + * Get the route guard. + * + * @return string|array{T,string} + */ + protected function get_guard(): string|array { + return \method_exists( $this->handler->classname, $this->route_guard ) + ? array( $this->handler->target, $this->route_guard ) + : $this->route_guard; + } +} diff --git a/src/Functions/xwp-di-container-fns.php b/src/Functions/xwp-di-container-fns.php index d3b6862..60449b8 100644 --- a/src/Functions/xwp-di-container-fns.php +++ b/src/Functions/xwp-di-container-fns.php @@ -18,6 +18,33 @@ function xwp_app( string $container_id ): Container { return \XWP\DI\App_Factory::get( $container_id ); } +/** + * Create a new app container. + * + * @param array{ + * id: string, + * module: class-string, + * attributes?: bool, + * autowiring?: bool, + * compile?: bool, + * compile_class?: string, + * compile_dir?: string, + * proxies?: bool, + * } $app Application configuration. + * @param string $hook Hook to create the container on. + * @param int $priority Hook priority. + * @return true + */ +function xwp_load_app( array $app, string $hook = 'plugins_loaded', int $priority = PHP_INT_MIN ): bool { + return add_action( + $hook, + static function () use( $app ): void { + xwp_create_app( $app ); + }, + $priority, + ); +} + /** * Create a new app container. * @@ -36,3 +63,30 @@ function xwp_app( string $container_id ): Container { function xwp_create_app( array $args ): Container { return \XWP\DI\App_Factory::create( $args ); } + +/** + * Extend an application container definition. + * + * @param string $container Container ID. + * @param string|array $module Module classname or array of module classnames. + * @param 'before'|'after' $position Position to insert the module. + * @param string|null $target Target module to extend. + */ +function xwp_extend_app( string $container, string|array $module, string $position = 'after', ?string $target = null ): void { + if ( ! is_array( $module ) ) { + $module = array( $module ); + } + + \XWP\DI\App_Factory::extend( $container, $module, $position, $target ); +} + +/** + * Decompile an application container. + * + * @param string $container_id Container ID. + * @param bool $immediately Decompile now or on shutdown. + * @return bool + */ +function xwp_decompile_app( string $container_id, bool $immediately = false ): bool { + return \XWP\DI\App_Factory::decompile( $container_id, $immediately ); +} diff --git a/src/Hook/Handler_Factory.php b/src/Handler_Factory.php similarity index 98% rename from src/Hook/Handler_Factory.php rename to src/Handler_Factory.php index 2e2d681..0632878 100644 --- a/src/Hook/Handler_Factory.php +++ b/src/Handler_Factory.php @@ -6,7 +6,7 @@ * @subpackage Dependency Injection */ -namespace XWP\DI\Hook; +namespace XWP\DI; use XWP\DI\Decorators\Handler; use XWP\DI\Interfaces\Can_Handle; diff --git a/src/Hook/Context.php b/src/Hook_Context.php similarity index 96% rename from src/Hook/Context.php rename to src/Hook_Context.php index 0134706..47fdb09 100644 --- a/src/Hook/Context.php +++ b/src/Hook_Context.php @@ -6,7 +6,7 @@ * @subpackage Dependency Injection */ -namespace XWP\DI\Hook; +namespace XWP\DI; use Automattic\Jetpack\Constants; @@ -15,7 +15,7 @@ * * @since 1.0.0 */ -final class Context { +final class Hook_Context { /** * Frontend context. */ @@ -81,7 +81,7 @@ public static function get(): int { * @param int $context The context to check. * @return bool */ - public static function is_valid_context( int $context ): bool { + public static function validate( int $context ): bool { return 0 !== ( self::get() & $context ); } diff --git a/src/Interfaces/Can_Handle.php b/src/Interfaces/Can_Handle.php index acc8686..e1893d2 100644 --- a/src/Interfaces/Can_Handle.php +++ b/src/Interfaces/Can_Handle.php @@ -71,4 +71,11 @@ public function get_target(): ?object; * @return bool */ public function is_lazy(): bool; + + /** + * Is the handler hookable? + * + * @return bool + */ + public function is_hookable(): bool; } diff --git a/src/Interfaces/Can_Invoke.php b/src/Interfaces/Can_Invoke.php index 4d9c10c..26abb5d 100644 --- a/src/Interfaces/Can_Invoke.php +++ b/src/Interfaces/Can_Invoke.php @@ -11,13 +11,15 @@ /** * Defines decorators that can invoke WordPress hooks. * - * @template THndlr of object - * @extends Can_Hook + * @template TInst of object + * @template THndl of Can_Handle + * @extends Can_Hook * * @property-read bool $firing Is the hook firing? - * @property-read int $fired Number of times the hook has fired. + * @property-read int $fired Number of times the hook has fired. - * @property-read array{THndlr,string} $target The target method. + * @property-read array{TInst,string} $target The target method. + * @property-read Thndl $handler The handler instance. */ interface Can_Invoke extends Can_Hook { /** @@ -47,8 +49,7 @@ interface Can_Invoke extends Can_Hook { /** * Set the handler instance. * - * @template Thndlr of object - * @param Can_Handle $handler Handler instance. + * @param THndl $handler Handler instance. * @return static */ public function with_handler( Can_Handle $handler ): static; diff --git a/src/Invoker.php b/src/Invoker.php index 647c3b7..576d31b 100644 --- a/src/Invoker.php +++ b/src/Invoker.php @@ -9,7 +9,7 @@ namespace XWP\DI; use XWP\DI\Decorators\Module; -use XWP\DI\Hook\Handler_Factory; +use XWP\DI\Handler_Factory; use XWP\DI\Interfaces\Can_Handle; use XWP\DI\Interfaces\Can_Invoke; use XWP\DI\Utils\Reflection; @@ -95,7 +95,7 @@ public function has_hooks( string $classname ): bool { * * @template T of object * @param class-string $classname The handler classname. - * @return array>> + * @return array>>> */ public function get_hooks( string $classname ): array { return $this->has_hooks( $classname ) ? $this->hooks[ $classname ] : array(); @@ -226,7 +226,7 @@ protected function init_handler( Can_Handle $handler ): static { * @return static */ protected function register_methods( Can_Handle $handler ): static { - if ( $this->has_hooks( $handler->classname ) ) { + if ( $this->has_hooks( $handler->classname ) || ! $handler->is_hookable() ) { return $this; } @@ -247,18 +247,20 @@ protected function register_methods( Can_Handle $handler ): static { * Register a method. * * @template T of object - * @param Can_Handle $handler The handler to register the method for. - * @param \ReflectionMethod $m The method to register. - * @return array> + * @template H of Can_Handle + * + * @param H $handler The handler to register the method for. + * @param \ReflectionMethod $method The method to register. + * @return array> */ - private function register_method( Can_Handle $handler, \ReflectionMethod $m ) { + private function register_method( Can_Handle $handler, \ReflectionMethod $method ) { $hooks = array(); - foreach ( Reflection::get_decorators( $m, Can_Invoke::class ) as $hook ) { + foreach ( Reflection::get_decorators( $method, Can_Invoke::class ) as $hook ) { $hooks[] = $hook - ->with_handler( $handler ) - ->with_target( $m->getName() ) - ->with_reflector( $m ); + ->with_reflector( $method ) + ->with_target( $method->getName() ) + ->with_handler( $handler ); } return $hooks; @@ -311,6 +313,8 @@ public function invoke_methods( Can_Handle $handler ): static { } } + \do_action( 'xwp_di_hooks_loaded_' . $handler->classname, $handler ); + return $this; } @@ -318,8 +322,9 @@ public function invoke_methods( Can_Handle $handler ): static { * Load hooks for a handler. * * @template T of object - * @param Can_Handle $handler The handler to load hooks for. - * @param array>> $hooks The hooks to load. + * @template H of Can_Handle + * @param H $handler The handler to load hooks for. + * @param array>> $hooks The hooks to load. * @return static */ public function load_hooks( Can_Handle $handler, array $hooks ): static { diff --git a/src/Traits/Accessible_Hook_Methods.php b/src/Traits/Accessible_Hook_Methods.php index d095a3d..4811e3d 100644 --- a/src/Traits/Accessible_Hook_Methods.php +++ b/src/Traits/Accessible_Hook_Methods.php @@ -52,7 +52,7 @@ public function __call( string $name, array $arguments ) { * @throws \BadMethodCallException If the method does not exist or is not hooked. */ public static function __callStatic( string $name, array $arguments ) { - if ( 'can_loadialize' === $name ) { + if ( 'can_initialize' === $name ) { return true; }