diff --git a/README.md b/README.md index 6c9773d..3fe0f85 100644 --- a/README.md +++ b/README.md @@ -264,3 +264,79 @@ $container ->unless(fn($container) => $container->has('FileDriver'), FileDriverServiceFactory($container)) ->unless(fn($container) => $container->has('Logger'), LoggerServiceFactory($container)); ``` + +## Pipeline Support class + +Pipelines allow for a middleware-like interface to chain processing of tasks. + +A pipeline processes each task, passed the returned value to the next process in the chain. + +They are useful for multi-step data processing, http middleware, database querying and validation tasks. + +Here's an example of how to use it to validation, filter, transform and save an incoming get request. + +```php +class PrepareRequest +{ + public function handle($request, $next) + { + $uri = $request->getUri(); + $query = $uri->getQuery(); // Get the query string (e.g., "param1=value1¶m2=value2") + parse_str($query, $queryParams); // Parse the query string into an array + + return $next($request); + } +} + +// 1. Create a custom pipe for validation +class ValidateRequest +{ + public function handle($request, $next) + { + // Validate parameters (e.g., check if 'name', 'age', and 'email' exist) + + return $next($request); + } +} + +// 2. Create a pipe for data transformation +class TransformRequest +{ + public function handle($request, $next) + { + // Capitalize the 'name' parameter + $request['name'] = ucfirst($request['name']); + + return $next($request); + } +} + +// 3. Create a pipe for Saving the data +class SaveRequest +{ + public function handle($request, $next) + { + // Save to database + + return $next($request); + } +} + +App::get('/', function ($request) { + // 4. Define the pipeline + $result = (new Pipeline(App::getContainer())) + ->send($request) + ->through([ + PrepareRequest::class, + ValidateRequest::class, + TransformRequest::class, + SaveRequest::class, + ]) + ->thenReturn(); + + // 5. Respond with the processed data + return response()->json(['message' => 'Request processed successfully', 'result' => $result])->get(); +}); +``` + +This way our controller stays clean, and readable, and each responsibility is separated to it's own class to make maintainance easier in the long run. This would also make testing easier, as you could test the individual classes, and also the overall pipeline result, without needing to test the controller itself. diff --git a/composer.json b/composer.json index 781d919..4d51c4b 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,11 @@ "./src/Support/helpers.php" ] }, + "autoload-dev": { + "psr-4": { + "SlimFacades\\Tests\\": "tests/" + } + }, "require": { "php": "^8.1", "psr/http-factory": "^1.0" diff --git a/src/Contracts/Pipeline.php b/src/Contracts/Pipeline.php new file mode 100644 index 0000000..0fa1c4c --- /dev/null +++ b/src/Contracts/Pipeline.php @@ -0,0 +1,40 @@ +getContainer()); + } + + /** + * @inheritDoc + */ + protected static function getFacadeAccessor(): string + { + return 'pipeline'; + } +} \ No newline at end of file diff --git a/src/Support/Pipeline.php b/src/Support/Pipeline.php new file mode 100644 index 0000000..79d5a8d --- /dev/null +++ b/src/Support/Pipeline.php @@ -0,0 +1,271 @@ +container = $container; + } + + /** + * Set the object being sent through the pipeline. + * + * @param mixed $passable + * @return $this + */ + public function send($passable) + { + $this->passable = $passable; + + return $this; + } + + /** + * Set the array of pipes. + * + * @param array|mixed $pipes + * @return $this + */ + public function through($pipes) + { + $this->pipes = is_array($pipes) ? $pipes : func_get_args(); + + return $this; + } + + /** + * Push additional pipes onto the pipeline. + * + * @param array|mixed $pipes + * @return $this + */ + public function pipe($pipes) + { + array_push($this->pipes, ...(is_array($pipes) ? $pipes : func_get_args())); + + return $this; + } + + /** + * Set the method to call on the pipes. + * + * @param string $method + * @return $this + */ + public function via($method) + { + $this->method = $method; + + return $this; + } + + /** + * Run the pipeline with a final destination callback. + * + * @param \Closure $destination + * @return mixed + */ + public function then(\Closure $destination) + { + $pipeline = array_reduce( + array_reverse($this->pipes()), $this->carry(), $this->prepareDestination($destination) + ); + + return $pipeline($this->passable); + } + + /** + * Run the pipeline and return the result. + * + * @return mixed + */ + public function thenReturn() + { + return $this->then(function ($passable) { + return $passable; + }); + } + + /** + * Get the final piece of the Closure onion. + * + * @param \Closure $destination + * @return \Closure + */ + protected function prepareDestination(\Closure $destination) + { + return function ($passable) use ($destination) { + try { + return $destination($passable); + } catch (\Throwable $e) { + return $this->handleException($passable, $e); + } + }; + } + + /** + * Get a Closure that represents a slice of the application onion. + * + * @return \Closure + */ + protected function carry() + { + return function ($stack, $pipe) { + return function ($passable) use ($stack, $pipe) { + try { + if (is_callable($pipe)) { + // If the pipe is a callable, then we will call it directly, but otherwise we + // will resolve the pipes out of the dependency container and call it with + // the appropriate method and arguments, returning the results back out. + return $pipe($passable, $stack); + } elseif (! is_object($pipe)) { + [$name, $parameters] = $this->parsePipeString($pipe); + + // If the pipe is a string we will parse the string and resolve the class out + // of the dependency injection container. We can then build a callable and + // execute the pipe function giving in the parameters that are required. + $pipe = $this->getContainer()->get($name); + + $parameters = array_merge([$passable, $stack], $parameters); + } else { + // If the pipe is already an object we'll just make a callable and pass it to + // the pipe as-is. There is no need to do any extra parsing and formatting + // since the object we're given was already a fully instantiated object. + $parameters = [$passable, $stack]; + } + + $carry = method_exists($pipe, $this->method) + ? $pipe->{$this->method}(...$parameters) + : $pipe(...$parameters); + + return $this->handleCarry($carry); + } catch (\Throwable $e) { + return $this->handleException($passable, $e); + } + }; + }; + } + + /** + * Parse full pipe string to get name and parameters. + * + * @param string $pipe + * @return array + */ + protected function parsePipeString($pipe) + { + [$name, $parameters] = array_pad(explode(':', $pipe, 2), 2, []); + + if (is_string($parameters)) { + $parameters = explode(',', $parameters); + } + + return [$name, $parameters]; + } + + /** + * Get the array of configured pipes. + * + * @return array + */ + protected function pipes() + { + return $this->pipes; + } + + /** + * Get the container instance. + * + * @return \Psr\Container\ContainerInterface + * + * @throws \RuntimeException + */ + protected function getContainer() + { + if (! $this->container) { + throw new \RuntimeException('A container instance has not been passed to the Pipeline.'); + } + + return $this->container; + } + + /** + * Set the container instance. + * + * @param \Psr\Container\ContainerInterface $container + * @return $this + */ + public function setContainer(ContainerInterface $container) + { + $this->container = $container; + + return $this; + } + + /** + * Handle the value returned from each pipe before passing it to the next. + * + * @param mixed $carry + * @return mixed + */ + protected function handleCarry($carry) + { + return $carry; + } + + /** + * Handle the given exception. + * + * @param mixed $passable + * @param \Throwable $e + * @return mixed + * + * @throws \Throwable + */ + protected function handleException($passable, \Throwable $e) + { + throw $e; + } +} diff --git a/tests/Facades/AppTest.php b/tests/Facades/AppTest.php index f0778f4..6404b53 100644 --- a/tests/Facades/AppTest.php +++ b/tests/Facades/AppTest.php @@ -1,5 +1,7 @@ assertInstanceOf(Slim\App::class, Facade::getFacadeApplication()); + $this->assertInstanceOf(\Slim\App::class, Facade::getFacadeApplication()); } public function testFacadeCallsUnderlyingObject() diff --git a/tests/Facades/PipelineTest.php b/tests/Facades/PipelineTest.php new file mode 100644 index 0000000..f3599cb --- /dev/null +++ b/tests/Facades/PipelineTest.php @@ -0,0 +1,31 @@ +app = Bridge::create(); + Facade::setFacadeApplication($this->app); + } + + public function tearDown(): void + { + unset($this->app); + } + + + public function testPipelineMethodsExist() + { + $this->assertInstanceOf(PipelineContract::class, Pipeline::getFacadeRoot()); + } +} diff --git a/tests/Facades/ResponseTest.php b/tests/Facades/ResponseTest.php index 467dc05..831bdbe 100644 --- a/tests/Facades/ResponseTest.php +++ b/tests/Facades/ResponseTest.php @@ -1,5 +1,7 @@ container = $app->getContainer(); + } + + public function testPipelineBasicUsage() + { + $pipeTwo = function ($piped, $next) { + $_SERVER['__test.pipe.two'] = $piped; + + return $next($piped); + }; + + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([PipelineTestPipeOne::class, $pipeTwo]) + ->then(function ($piped) { + return $piped; + }); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + $this->assertSame('foo', $_SERVER['__test.pipe.two']); + + unset($_SERVER['__test.pipe.one'], $_SERVER['__test.pipe.two']); + } + + public function testPipelineUsageWithObjects() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([new PipelineTestPipeOne]) + ->then(function ($piped) { + return $piped; + }); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + + public function testPipelineUsageWithInvokableObjects() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([new PipelineTestPipeTwo]) + ->then( + function ($piped) { + return $piped; + } + ); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + + public function testPipelineUsageWithCallable() + { + $function = function ($piped, $next) { + $_SERVER['__test.pipe.one'] = 'foo'; + + return $next($piped); + }; + + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([$function]) + ->then( + function ($piped) { + return $piped; + } + ); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + + $result = (new Pipeline($this->container)) + ->send('bar') + ->through($function) + ->thenReturn(); + + $this->assertSame('bar', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + + public function testPipelineUsageWithPipe() + { + $object = new \stdClass(); + + $object->value = 0; + + $function = function ($object, $next) { + $object->value++; + + return $next($object); + }; + + $result = (new Pipeline($this->container)) + ->send($object) + ->through([$function]) + ->pipe([$function]) + ->then( + function ($piped) { + return $piped; + } + ); + + $this->assertSame($object, $result); + $this->assertEquals(2, $object->value); + } + + public function testPipelineUsageWithInvokableClass() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([PipelineTestPipeTwo::class]) + ->then( + function ($piped) { + return $piped; + } + ); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + + public function testThenMethodIsNotCalledIfThePipeReturns() + { + $_SERVER['__test.pipe.then'] = '(*_*)'; + $_SERVER['__test.pipe.second'] = '(*_*)'; + + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([ + fn ($value, $next) => 'm(-_-)m', + fn ($value, $next) => $_SERVER['__test.pipe.second'] = 'm(-_-)m', + ]) + ->then(function ($piped) { + $_SERVER['__test.pipe.then'] = '(0_0)'; + + return $piped; + }); + + $this->assertSame('m(-_-)m', $result); + // The then callback is not called. + $this->assertSame('(*_*)', $_SERVER['__test.pipe.then']); + // The second pipe is not called. + $this->assertSame('(*_*)', $_SERVER['__test.pipe.second']); + + unset($_SERVER['__test.pipe.then']); + } + + public function testThenMethodInputValue() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([function ($value, $next) { + $value = $next('::not_foo::'); + + $_SERVER['__test.pipe.return'] = $value; + + return 'pipe::'.$value; + }]) + ->then(function ($piped) { + $_SERVER['__test.then.arg'] = $piped; + + return 'then'.$piped; + }); + + $this->assertSame('pipe::then::not_foo::', $result); + $this->assertSame('::not_foo::', $_SERVER['__test.then.arg']); + + unset($_SERVER['__test.then.arg']); + unset($_SERVER['__test.pipe.return']); + } + + public function testPipelineUsageWithParameters() + { + $parameters = ['one', 'two']; + + $result = (new Pipeline($this->container)) + ->send('foo') + ->through(PipelineTestParameterPipe::class.':'.implode(',', $parameters)) + ->then(function ($piped) { + return $piped; + }); + + $this->assertSame('foo', $result); + $this->assertEquals($parameters, $_SERVER['__test.pipe.parameters']); + + unset($_SERVER['__test.pipe.parameters']); + } + + public function testPipelineViaChangesTheMethodBeingCalledOnThePipes() + { + $pipelineInstance = new Pipeline($this->container); + $result = $pipelineInstance->send('data') + ->through(PipelineTestPipeOne::class) + ->via('differentMethod') + ->then(function ($piped) { + return $piped; + }); + $this->assertSame('data', $result); + } + + public function testPipelineThrowsExceptionOnResolveWithoutContainer() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('A container instance has not been passed to the Pipeline.'); + + (new Pipeline)->send('data') + ->through(PipelineTestPipeOne::class) + ->then(function ($piped) { + return $piped; + }); + } + + public function testPipelineThenReturnMethodRunsPipelineThenReturnsPassable() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->through([PipelineTestPipeOne::class]) + ->thenReturn(); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + + unset($_SERVER['__test.pipe.one']); + } + + public function testPipelineConditionable() + { + $result = (new Pipeline($this->container)) + ->send('foo') + ->when(true, function (Pipeline $pipeline) { + $pipeline->pipe([PipelineTestPipeOne::class]); + }) + ->then(function ($piped) { + return $piped; + }); + + $this->assertSame('foo', $result); + $this->assertSame('foo', $_SERVER['__test.pipe.one']); + unset($_SERVER['__test.pipe.one']); + + $_SERVER['__test.pipe.one'] = null; + $result = (new Pipeline($this->container)) + ->send('foo') + ->when(false, function (Pipeline $pipeline) { + $pipeline->pipe([PipelineTestPipeOne::class]); + }) + ->then(function ($piped) { + return $piped; + }); + + $this->assertSame('foo', $result); + $this->assertNull($_SERVER['__test.pipe.one']); + unset($_SERVER['__test.pipe.one']); + } +} + +class PipelineTestPipeOne +{ + public function handle($piped, $next) + { + $_SERVER['__test.pipe.one'] = $piped; + + return $next($piped); + } + + public function differentMethod($piped, $next) + { + return $next($piped); + } +} + +class PipelineTestPipeTwo +{ + public function __invoke($piped, $next) + { + $_SERVER['__test.pipe.one'] = $piped; + + return $next($piped); + } +} + +class PipelineTestParameterPipe +{ + public function handle($piped, $next, $parameter1 = null, $parameter2 = null) + { + $_SERVER['__test.pipe.parameters'] = [$parameter1, $parameter2]; + + return $next($piped); + } +} diff --git a/tests/Support/Traits/ConditionableTest.php b/tests/Support/Traits/ConditionableTest.php index e570ee9..17427a1 100644 --- a/tests/Support/Traits/ConditionableTest.php +++ b/tests/Support/Traits/ConditionableTest.php @@ -1,5 +1,7 @@