laminas / laminas-stratigility

PSR-7 middleware foundation for building and dispatching middleware pipelines
https://docs.laminas.dev/laminas-stratigility/
BSD 3-Clause "New" or "Revised" License
55 stars 12 forks source link

`MiddlewarePipeInterface#getPipeline` to retrieve an iterable of the pipeline #44

Closed boesing closed 3 months ago

boesing commented 1 year ago

Feature Request

Q A
New Feature yes
RFC yes
BC Break yes

Summary

I'd love to see MiddlewarePipeInterface#getPipeline to retrieve an iterable of middlewares to be executed. Most preferably in the same order the middlewares were actually enqueued.

This method would help me to get rid of using ReflectionProperty in unit tests to actually grab the pipeline so that we can verify that all middlewares are actually instantiable via ContainerInterface:

<?php

declare(strict_types=1);

namespace ApplicationTest\DependencyInjection;

use ApplicationTest\AbstractContainerAwareTestCase;
use InvalidArgumentException;
use Laminas\Stratigility\MiddlewarePipe;
use Mezzio\Application;
use Mezzio\Middleware\LazyLoadingMiddleware;
use Mezzio\MiddlewareContainer;
use Mezzio\Router\FastRouteRouter;
use Mezzio\Router\Route;
use Mezzio\Router\RouterInterface;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use ReflectionClass;
use RuntimeException;
use Webmozart\Assert\Assert;
use function array_unique;
use function assert;
use function class_implements;
use function get_class;
use function in_array;
use function is_string;
use function sprintf;

final class DependencyFactoryIntegrationTest extends AbstractContainerAwareTestCase
{
    /**
     * @psalm-assert class-string<MiddlewareInterface|RequestHandlerInterface> $middlewareName
     */
    private static function assertIsMiddlewareOrRequestHandler(string $middlewareName): void
    {
        Assert::classExists($middlewareName);
        $implements = class_implements($middlewareName) ?: [];
        if (in_array(MiddlewareInterface::class, $implements, true)) {
            return;
        }
        if (in_array(RequestHandlerInterface::class, $implements, true)) {
            return;
        }

        throw new InvalidArgumentException(sprintf(
            'Provided middleware "%s" name does not implement MiddlewareInterface nor RequestHandlerInterface.',
            $middlewareName,
        ));
    }

    /**
     * @return non-empty-list<Route>
     */
    private static function extractRoutesFromConfiguration(ContainerInterface $container): array
    {
        /**
         * Load application as the applications delegator is actually adding routes to the router.
         *
         * @see \Mezzio\Container\ApplicationConfigInjectionDelegator
         */
        $container->get(Application::class);

        $router = $container->get(RouterInterface::class);
        self::assertInstanceOf(FastRouteRouter::class, $router);

        // Extract known routes
        $routerReflection = new ReflectionClass($router);
        $routesToInjectProperty = $routerReflection->getProperty('routesToInject');
        $routes = $routesToInjectProperty->getValue($router);
        Assert::isNonEmptyList($routes);
        Assert::allIsInstanceOf($routes, Route::class);

        return $routes;
    }

    /**
     * @dataProvider middlewares
     */
    public function testCanGetServiceFromContainer(ContainerInterface $container, string $serviceNameOrAlias): void
    {
        $this->expectNotToPerformAssertions();
        $container->get($serviceNameOrAlias);
    }

    private static function getContainerWithModifiedServices(): ContainerInterface
    {
        return self::getContainer();
    }

    /**
     * @return list<non-empty-string>
     */
    private static function extractServiceNamesFromRouteConfiguration(ContainerInterface $container): array
    {
        $routes = self::extractRoutesFromConfiguration($container);

        /** @var list<non-empty-string> $middlewareNames */
        $middlewareNames = [];

        foreach ($routes as $route) {
            assert($route instanceof Route);
            $middleware = $route->getMiddleware();

            $middlewareNamesFromMiddleware = self::extractMiddlewareNamesFromMiddleware($middleware);
            foreach ($middlewareNamesFromMiddleware as $middlewareName) {
                if (in_array($middlewareName, $middlewareNames, true)) {
                    continue;
                }

                $middlewareNames[] = $middlewareName;
            }
        }

        return $middlewareNames;
    }

    /**
     * @return non-empty-string
     */
    private static function extractMiddlewareNameFromLazyLoadingMiddleware(LazyLoadingMiddleware $middleware): string
    {
        // Extract middleware name from lazy loading middleware
        $middlewareReflection = new ReflectionClass($middleware);
        $middlewareNameProperty = $middlewareReflection->getProperty('middlewareName');

        $middlewareName = $middlewareNameProperty->getValue($middleware);
        assert(is_string($middlewareName) && $middlewareName !== '');

        return $middlewareName;
    }

    /**
     * @return list<non-empty-string>
     */
    private static function extractMiddlewareNamesFromMiddleware(MiddlewareInterface $middleware): array
    {
        if ($middleware instanceof LazyLoadingMiddleware) {
            return [
                self::extractMiddlewareNameFromLazyLoadingMiddleware($middleware),
            ];
        }

        if ($middleware instanceof MiddlewarePipe) {
            return self::extractMiddlewareNamesFromPipeline($middleware);
        }

        throw new RuntimeException(sprintf('Unhandled middleware type `%s`.', get_class($middleware)));
    }

    /**
     * @return list<non-empty-string>
     */
    private static function extractMiddlewareNamesFromPipeline(MiddlewarePipe $middleware): array
    {
        // Extract middleware name from lazy loading middleware
        $middlewareReflection = new ReflectionClass($middleware);
        $pipelineProperty = $middlewareReflection->getProperty('pipeline');

        $pipeline = $pipelineProperty->getValue($middleware);
        self::assertIsIterable($pipeline);
        $middlewareNames = [];

        foreach ($pipeline as $middleware) {
            assert($middleware instanceof MiddlewareInterface);
            $middlewareNames[] = self::extractMiddlewareNamesFromMiddleware($middleware);
        }

        $flattenedMiddlewareNames = array_merge(...$middlewareNames);

        return array_values(array_unique($flattenedMiddlewareNames));
    }

    /**
     * @return iterable<class-string<RequestHandlerInterface|MiddlewareInterface>,array{ContainerInterface,class-string<MiddlewareInterface|RequestHandlerInterface>}>
     */
    public static function middlewares(): iterable
    {
        $container = self::getContainerWithModifiedServices();

        $middlewareContainer = $container->get(MiddlewareContainer::class);
        $middlewares = [];

        foreach (self::extractServiceNamesFromRouteConfiguration($container) as $middlewareName) {
            if (in_array($middlewareName, $middlewares, true)) {
                continue;
            }

            self::assertIsMiddlewareOrRequestHandler($middlewareName);
            $middlewares[] = $middlewareName;

            yield $middlewareName => [$middlewareContainer, $middlewareName];
        }
    }

    /**
     * @param class-string<RequestHandlerInterface> $requestHandler
     * @dataProvider requestHandlers
     */
    public function testRequestHandlerInterfaceIsRegisteredToAnyRoute(
        string $requestHandler
    ): void {
        $routes = self::extractRoutesFromConfiguration(self::getContainerWithModifiedServices());

        foreach ($routes as $route) {
            $middlewareNames = self::extractMiddlewareNamesFromMiddleware($route->getMiddleware());
            if (!in_array($requestHandler, $middlewareNames, true)) {
                continue;
            }

            return;
        }

        self::fail(sprintf('Could not find any route using request handler: %s', $requestHandler));
    }

    /**
     * @return iterable<class-string<RequestHandlerInterface>,array{0: class-string<RequestHandlerInterface>}>
     */
    public static function requestHandlers(): iterable
    {
        $middlewares = self::middlewares();

        foreach ($middlewares as [, $middleware]) {
            if (!in_array(RequestHandlerInterface::class, class_implements($middleware) ?: [], true)) {
                continue;
            }

            yield $middleware => [$middleware];
        }
    }
}

More specifically the extractMiddlewareNamesFromPipeline method. With https://github.com/mezzio/mezzio/pull/159 it is now possible to fetch the middleware name which will be lazy-loaded by LazyLoadingMiddleware and thus, the only thing where I would need ReflectionProperty for would be the pipeline from stratigility (well, and the routesToInject to access FastRouteRouter pending route property...).

Xerkus commented 8 months ago

Is it worth introducing a BC break? Will new interface that our pipe implements be sufficient?

Say,


interface InspectableMiddlewarePipeInterface extends MiddlewarePipeInterface
{
    public function inspectPipedMiddleware(): array
}
boesing commented 8 months ago

Since we are releasing a major anyways, I'd say yes. Since every middleware Pipeline has a Pipeline, I think that is sufficient.

gsteel commented 8 months ago

Or implement IteratorAggregate too?

Xerkus commented 8 months ago

This is explicitly to inspect pipe and has nothing to do with its function. I don't think it should provide iterator.