inpsyde / modularity

A PSR-11 implementation for WordPress Plugins, Themes or Libraries.
https://inpsyde.github.io/modularity/
GNU General Public License v2.0
44 stars 4 forks source link

Introduce packages connection #16

Closed gmazzap closed 2 years ago

gmazzap commented 2 years ago

What kind of change does this PR introduce?

Feature (and related tests and docs update).

What is the current behavior?

In a package, it is not possible to use services from another package

What is the new behavior

We can "connect" packages and then access services from another package

Does this PR introduce a breaking change?

No, it does not. It introduces a new functionality in a backward-compatible way.

Please check if the PR fulfills these requirements


More details

The issue

In Modularity, we currently have "isolated" packages, meaning that a package's modules can only access services of the same package. That makes easy to create "extensions" for a specific theme/plugin/library, but it makes very hard to have "reusable" services that can be used by several themes/plugins/libraries.

The proposed solution

This PR introduces a Package::connect() method, which accepts as argument another package.

In that method, we take the PSR-11 Container from the given package, and we "add" it to the "containers stack", in the same way we would add a container when instantiating the Package, via Package::new($properties, $container1, $containerN).

As a consequence, we need to call Package::connect() before calling Package::boot(): after that, the Package read-only container is already built, and we can't add more inner containers to it.

As additional consequence, in theory, we could connect a package only if that package is booted, otherwise we can't retrieve its read-only container to be added to the calling package's containers stack.

However, that would be too limitating. And to solve that, this PR introduce a PackageProxyContainer class: a PSR-11 container that holds an instance of Package, and resolve services by first resolving package's container, assuming it is booted.

It's nothing more than an implementation of the proxy pattern: it allows us to have an object resembling a PSR-11 container, before the subject PSR-11 container is available.

Example use case

The first use case, could be to have a library that provides services, from one or more modules, but does not really use it, but ony makes them available for other libraries/plugins/themes to use.

Let's assume a library like this:

namespace Acme;

use Inpsyde\Modularity;

function coolLibrary(): Modularity\Package {
    static $package;
    if (!$package) {
        $properties = Modularity\Properties\LibraryProperties::new(__DIR__ . '/composer.json');
        $package = Modularity\Package::new($properties);
        $package->boot(new LibModuleOne(), new LibModuleTwo());
    }
    return $package;
}

The function would be available via Composer, but until it is called by some other code, it will do nothing.

But we might have several plugins that could do:

namespace Acme;

use Inpsyde\Modularity;

Modularity\Package::new(Modularity\Properties\PluginProperties::new(__FILE__))
    ->addModule(new PluginModuleOne())
    ->addModule(new PluginModuleTwo())
    ->connect(coolLibrary())  // <- note this
    ->boot();

Gotcha

In the example above, ->connect(coolLibrary()) is executed before boot and that is a requirement. The package returned by coolLibrary() is also booted, because of how the coolLibrary() function is written.

But we could have written it like this:

function coolLibrary(): Modularity\Package {
    static $package;
    if (!$package) {
        $properties = Modularity\Properties\LibraryProperties::new(__DIR__ . '/composer.json');
        $package = Modularity\Package::new($properties)
            ->addModule(new PluginModuleOne())
            ->addModule(new PluginModuleTwo());

        add_action('wp', [$package, 'boot']);
    }
    return $package;
}

Here we're "booting" the library only at wp hook, and not immediately.

Because the plugin calls ->connect(coolLibrary()) before wp hook, the library container will not be ready at that point. But that is fine, assuming plugin's modules take care of accessing library's services after that hook.

In fact, PluginModuleOne could look like this:

use Inpsyde\Modularity\Module;
use Psr\Container\ContainerInterface as Container;

class PluginModuleOne implements Module\ServiceModule, Module\ExecutableModule
{
    public function services() : array
    {
        return [
            'my_service' => fn(Container $c) => new PluginService($c->get('a_service_from_cool_library'));
        ];
    }

    public function run(Container $c): bool
    {
        return add_action('template_redirect', fn() => $c->get('my_service')->doSomething());
    }
}

The plugin my_service is accessed on template_redirect. That causes a resolution in the container of a_service_from_cool_library which is provided by the connected library, and that works: template_redirect happens after wp hook (when the library is booted), so at that point is possible to retrieve library services.

Basically, when you connect a package is your responsibility to call its services after the connected package boots.

A "special case" is when we want to use a theme's services from a plugin.

The theme will be booted after the plugin, but we need to connect the theme before the plugin boots.

The problem is we can't call the theme package, because it will not even be loaded yet when the plugin loads.

In that case, our plugin should probably looks like:

namespace Acme;

use Inpsyde\Modularity;

$plugin = Modularity\Package::new(Modularity\Properties\PluginProperties::new(__FILE__))
    ->addModule(new PluginModuleOne())
    ->addModule(new PluginModuleTwo());

add_action('after_setup_theme', function () use ($plugin) {
    $plugin
        ->connect(coolTheme())   // <- note this
        ->boot();
});

Full list of changes

Development

Unit tests

Psalm

Misc

Docs

Misc